feat(webhooks): P2-082 P2-083 signature verification and delivery logging

P2-082: Webhook Signature Verification
- Add require_signature field, verifySignatureWithDetails()
- Support grace period during secret rotation
- Log signature failures for audit

P2-083: Webhook Delivery Logging
- WebhookDeliveryLogger service for centralised logging
- Track duration, response code, signature verification
- Add getDeliveryStats() and getRecentSignatureFailures()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 14:22:09 +00:00
parent fa4893d064
commit 0120908669
10 changed files with 1918 additions and 98 deletions

View file

@ -11,15 +11,27 @@ use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Jobs\ProcessContentWebhook;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Mod\Content\Services\WebhookDeliveryLogger;
/**
* Controller for receiving external content webhooks.
*
* Handles incoming webhooks from WordPress, CMS systems, and custom integrations.
* Webhooks are logged and dispatched to a job for async processing.
*
* Security features (P2-082, P2-083):
* - HMAC-SHA256 signature verification with timing-safe comparison
* - Grace period support for secret rotation
* - Comprehensive delivery logging for audit trails
* - Failed signature attempts are logged without storing payload
*/
class ContentWebhookController extends Controller
{
public function __construct(
protected WebhookDeliveryLogger $deliveryLogger
) {
}
/**
* Receive a webhook from an external source.
*
@ -50,17 +62,26 @@ class ContentWebhookController extends Controller
// Get raw payload
$payload = $request->getContent();
// Verify signature if secret is configured
// Extract signature and verify with detailed result
$signature = $this->extractSignature($request);
if (! $endpoint->verifySignature($payload, $signature)) {
Log::warning('Content webhook signature verification failed', [
'endpoint_id' => $endpoint->id,
'source_ip' => $request->ip(),
]);
$verificationResult = $endpoint->verifySignatureWithDetails($payload, $signature);
if (! $verificationResult['verified']) {
// Log the failed attempt for security audit trail (P2-082)
$this->deliveryLogger->logSignatureFailure(
$request,
$endpoint,
$verificationResult['reason']
);
return response('Invalid signature', 401);
}
// Log when signature verification is explicitly bypassed (P2-082)
if ($verificationResult['reason'] === ContentWebhookEndpoint::SIGNATURE_SUCCESS_NOT_REQUIRED) {
$this->deliveryLogger->logSignatureNotRequired($request, $endpoint);
}
// Parse payload
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
@ -86,23 +107,21 @@ class ContentWebhookController extends Controller
return response('Event type not allowed', 403);
}
// Create webhook log entry
$log = ContentWebhookLog::create([
'workspace_id' => $endpoint->workspace_id,
'endpoint_id' => $endpoint->id,
'event_type' => $eventType,
'wp_id' => $this->extractContentId($data),
'content_type' => $this->extractContentType($data),
'payload' => $data,
'status' => 'pending',
'source_ip' => $request->ip(),
]);
// Create webhook log entry with full delivery details (P2-083)
$log = $this->deliveryLogger->createDeliveryLog(
$request,
$endpoint,
$data,
$eventType,
$verificationResult
);
Log::info('Content webhook received', [
'log_id' => $log->id,
'endpoint_id' => $endpoint->id,
'event_type' => $eventType,
'workspace_id' => $endpoint->workspace_id,
'signature_status' => $verificationResult['reason'],
]);
// Update endpoint last received timestamp
@ -277,47 +296,4 @@ class ContentWebhookController extends Controller
return 'wordpress.post_updated';
}
/**
* Extract content ID from payload.
*/
protected function extractContentId(array $data): ?int
{
// Try various ID field names
$idFields = ['post_id', 'ID', 'id', 'content_id', 'item_id'];
foreach ($idFields as $field) {
if (isset($data[$field])) {
return (int) $data[$field];
}
// Check nested data
if (isset($data['data'][$field])) {
return (int) $data['data'][$field];
}
}
return null;
}
/**
* Extract content type from payload.
*/
protected function extractContentType(array $data): ?string
{
// Try various type field names
$typeFields = ['post_type', 'content_type', 'type'];
foreach ($typeFields as $field) {
if (isset($data[$field])) {
return (string) $data[$field];
}
// Check nested data
if (isset($data['data'][$field])) {
return (string) $data['data'][$field];
}
}
return null;
}
}

View file

@ -59,6 +59,7 @@ class ProcessContentWebhook implements ShouldQueue
*/
public function handle(): void
{
$startTime = hrtime(true);
$this->webhookLog->markProcessing();
Log::info('Processing content webhook', [
@ -74,7 +75,11 @@ class ProcessContentWebhook implements ShouldQueue
default => $this->processGeneric(),
};
$this->webhookLog->markCompleted();
// Calculate processing duration in milliseconds
$durationMs = (int) ((hrtime(true) - $startTime) / 1_000_000);
// Mark completed with duration tracking
$this->webhookLog->markCompletedWithDetails($durationMs);
// Reset failure count on endpoint
if ($endpoint = $this->getEndpoint()) {
@ -84,10 +89,13 @@ class ProcessContentWebhook implements ShouldQueue
Log::info('Content webhook processed successfully', [
'log_id' => $this->webhookLog->id,
'event_type' => $this->webhookLog->event_type,
'duration_ms' => $durationMs,
'result' => $result,
]);
} catch (\Exception $e) {
$this->handleFailure($e);
// Calculate duration even on failure
$durationMs = (int) ((hrtime(true) - $startTime) / 1_000_000);
$this->handleFailure($e, $durationMs);
throw $e;
}
}
@ -509,10 +517,16 @@ class ProcessContentWebhook implements ShouldQueue
/**
* Handle job failure.
*
* @param int|null $durationMs Processing duration in milliseconds
*/
protected function handleFailure(\Exception $e): void
protected function handleFailure(\Exception $e, ?int $durationMs = null): void
{
$this->webhookLog->markFailed($e->getMessage());
if ($durationMs !== null) {
$this->webhookLog->markFailedWithDetails($e->getMessage(), $durationMs);
} else {
$this->webhookLog->markFailed($e->getMessage());
}
// Increment failure count on endpoint
if ($endpoint = $this->getEndpoint()) {
@ -523,6 +537,7 @@ class ProcessContentWebhook implements ShouldQueue
'log_id' => $this->webhookLog->id,
'event_type' => $this->webhookLog->event_type,
'error' => $e->getMessage(),
'duration_ms' => $durationMs,
'attempts' => $this->attempts(),
]);
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Add signature verification requirement and delivery logging fields.
*
* P2-082: Adds require_signature field to enforce signature verification
* P2-083: Adds delivery logging fields for comprehensive webhook tracking
*/
return new class extends Migration
{
public function up(): void
{
// Add require_signature field to webhook endpoints
Schema::table('content_webhook_endpoints', function (Blueprint $table) {
// Require signature verification by default (security-first approach)
// Set to false to explicitly allow unsigned webhooks
$table->boolean('require_signature')->default(true)->after('secret');
});
// Add delivery logging fields to webhook logs
Schema::table('content_webhook_logs', function (Blueprint $table) {
// Signature verification tracking
$table->boolean('signature_verified')->nullable()->after('source_ip');
$table->string('signature_failure_reason', 100)->nullable()->after('signature_verified');
// Processing duration tracking (in milliseconds)
$table->unsignedInteger('processing_duration_ms')->nullable()->after('processed_at');
// Request/response details
$table->json('request_headers')->nullable()->after('payload');
$table->unsignedSmallInteger('response_code')->nullable()->after('processing_duration_ms');
$table->text('response_body')->nullable()->after('response_code');
// Index for querying verification failures
$table->index('signature_verified', 'webhook_signature_verified_idx');
});
}
public function down(): void
{
Schema::table('content_webhook_logs', function (Blueprint $table) {
$table->dropIndex('webhook_signature_verified_idx');
$table->dropColumn([
'signature_verified',
'signature_failure_reason',
'processing_duration_ms',
'request_headers',
'response_code',
'response_body',
]);
});
Schema::table('content_webhook_endpoints', function (Blueprint $table) {
$table->dropColumn('require_signature');
});
}
};

View file

@ -47,6 +47,7 @@ class ContentWebhookEndpoint extends Model
'workspace_id',
'name',
'secret',
'require_signature',
'previous_secret',
'secret_rotated_at',
'grace_period_seconds',
@ -59,6 +60,7 @@ class ContentWebhookEndpoint extends Model
protected $casts = [
'allowed_types' => 'array',
'is_enabled' => 'boolean',
'require_signature' => 'boolean',
'failure_count' => 'integer',
'last_received_at' => 'datetime',
'secret' => 'encrypted',
@ -191,6 +193,100 @@ class ContentWebhookEndpoint extends Model
// Signature Verification
// -------------------------------------------------------------------------
/**
* Signature verification failure reasons.
*/
public const SIGNATURE_FAILURE_NO_SECRET = 'no_secret_configured';
public const SIGNATURE_FAILURE_NOT_REQUIRED = 'signature_not_required';
public const SIGNATURE_FAILURE_MISSING = 'signature_missing';
public const SIGNATURE_FAILURE_INVALID = 'signature_invalid';
public const SIGNATURE_SUCCESS = 'verified';
public const SIGNATURE_SUCCESS_GRACE = 'verified_grace_period';
public const SIGNATURE_SUCCESS_NOT_REQUIRED = 'not_required';
/**
* Verify webhook signature with detailed result.
*
* Supports multiple signature formats:
* - X-Signature: HMAC-SHA256 signature of the raw body
* - X-Hub-Signature-256: GitHub-style sha256=... format
* - X-WP-Webhook-Signature: WordPress webhook signature
*
* During a grace period after secret rotation, both current and
* previous secrets are accepted to avoid breaking integrations.
*
* @param string $payload The raw request body
* @param string|null $signature The signature from request header
* @return array{verified: bool, reason: string} Verification result with reason
*/
public function verifySignatureWithDetails(string $payload, ?string $signature): array
{
// Check if signature is required
$requireSignature = $this->require_signature ?? true;
// If no secret configured
if (empty($this->secret)) {
if ($requireSignature) {
// Security: reject if signature is required but no secret configured
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_NO_SECRET,
];
}
// Signature explicitly not required - allow through but log
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS_NOT_REQUIRED,
];
}
// Signature required when secret is set
if (empty($signature)) {
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_MISSING,
];
}
// Normalise signature (handle sha256=... format)
$normalised = $signature;
if (str_starts_with($signature, 'sha256=')) {
$normalised = substr($signature, 7);
}
// Check against current secret
$expectedSignature = hash_hmac('sha256', $payload, $this->secret);
if (hash_equals($expectedSignature, $normalised)) {
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS,
];
}
// Check against previous secret if in grace period
if ($this->isInGracePeriod() && ! empty($this->previous_secret)) {
$previousExpectedSignature = hash_hmac('sha256', $payload, $this->previous_secret);
if (hash_equals($previousExpectedSignature, $normalised)) {
return [
'verified' => true,
'reason' => self::SIGNATURE_SUCCESS_GRACE,
];
}
}
return [
'verified' => false,
'reason' => self::SIGNATURE_FAILURE_INVALID,
];
}
/**
* Verify webhook signature.
*
@ -201,39 +297,20 @@ class ContentWebhookEndpoint extends Model
*
* During a grace period after secret rotation, both current and
* previous secrets are accepted to avoid breaking integrations.
*
* @deprecated Use verifySignatureWithDetails() for detailed failure reasons
*/
public function verifySignature(string $payload, ?string $signature): bool
{
// If no secret configured, skip verification (but log warning)
if (empty($this->secret)) {
return true;
}
return $this->verifySignatureWithDetails($payload, $signature)['verified'];
}
// Signature required when secret is set
if (empty($signature)) {
return false;
}
// Normalise signature (handle sha256=... format)
if (str_starts_with($signature, 'sha256=')) {
$signature = substr($signature, 7);
}
// Check against current secret
$expectedSignature = hash_hmac('sha256', $payload, $this->secret);
if (hash_equals($expectedSignature, $signature)) {
return true;
}
// Check against previous secret if in grace period
if ($this->isInGracePeriod() && ! empty($this->previous_secret)) {
$previousExpectedSignature = hash_hmac('sha256', $payload, $this->previous_secret);
if (hash_equals($previousExpectedSignature, $signature)) {
return true;
}
}
return false;
/**
* Check if this endpoint requires signature verification.
*/
public function requiresSignature(): bool
{
return $this->require_signature ?? true;
}
// -------------------------------------------------------------------------

View file

@ -25,10 +25,16 @@ class ContentWebhookLog extends Model
'wp_id',
'content_type',
'payload',
'request_headers',
'status',
'error_message',
'source_ip',
'signature_verified',
'signature_failure_reason',
'processed_at',
'processing_duration_ms',
'response_code',
'response_body',
'retry_count',
'max_retries',
'next_retry_at',
@ -37,10 +43,14 @@ class ContentWebhookLog extends Model
protected $casts = [
'payload' => 'array',
'request_headers' => 'array',
'processed_at' => 'datetime',
'next_retry_at' => 'datetime',
'retry_count' => 'integer',
'max_retries' => 'integer',
'signature_verified' => 'boolean',
'processing_duration_ms' => 'integer',
'response_code' => 'integer',
];
/**
@ -91,6 +101,73 @@ class ContentWebhookLog extends Model
]);
}
/**
* Record signature verification result.
*/
public function recordSignatureVerification(bool $verified, string $reason): void
{
$this->update([
'signature_verified' => $verified,
'signature_failure_reason' => $verified ? null : $reason,
]);
}
/**
* Record processing completion with duration.
*
* @param int $durationMs Processing duration in milliseconds
* @param int|null $responseCode HTTP response code if applicable
* @param string|null $responseBody Response body if applicable
*/
public function recordProcessingComplete(
int $durationMs,
?int $responseCode = null,
?string $responseBody = null
): void {
$this->update([
'processing_duration_ms' => $durationMs,
'response_code' => $responseCode,
'response_body' => $responseBody ? substr($responseBody, 0, 10000) : null, // Limit size
]);
}
/**
* Mark as completed with full details.
*/
public function markCompletedWithDetails(
int $durationMs,
?int $responseCode = null,
?string $responseBody = null
): void {
$this->update([
'status' => 'completed',
'processed_at' => now(),
'error_message' => null,
'processing_duration_ms' => $durationMs,
'response_code' => $responseCode,
'response_body' => $responseBody ? substr($responseBody, 0, 10000) : null,
]);
}
/**
* Mark as failed with full details.
*/
public function markFailedWithDetails(
string $error,
int $durationMs,
?int $responseCode = null,
?string $responseBody = null
): void {
$this->update([
'status' => 'failed',
'processed_at' => now(),
'error_message' => $error,
'processing_duration_ms' => $durationMs,
'response_code' => $responseCode,
'response_body' => $responseBody ? substr($responseBody, 0, 10000) : null,
]);
}
/**
* Scope to filter by workspace.
*/
@ -115,6 +192,22 @@ class ContentWebhookLog extends Model
return $query->where('status', 'failed');
}
/**
* Scope to webhooks with signature verification failures.
*/
public function scopeSignatureFailed($query)
{
return $query->where('signature_verified', false);
}
/**
* Scope to webhooks with successful signature verification.
*/
public function scopeSignatureVerified($query)
{
return $query->where('signature_verified', true);
}
/**
* Scope to webhooks that are ready for retry.
*

View file

@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
/**
* WebhookDeliveryLogger
*
* Centralised service for logging webhook deliveries, signature verification
* results, and processing metrics. Provides comprehensive audit trails for
* security and debugging purposes.
*
* P2-082: Implements signature verification audit logging
* P2-083: Implements comprehensive delivery logging
*/
class WebhookDeliveryLogger
{
/**
* Headers that are safe to log (excluding sensitive data like signatures).
*/
protected const SAFE_HEADERS = [
'Content-Type',
'Content-Length',
'User-Agent',
'X-Event-Type',
'X-WP-Webhook-Event',
'X-Content-Event',
'X-Request-Id',
'X-Correlation-Id',
'Accept',
'Accept-Encoding',
];
/**
* Log a successful webhook delivery with full details.
*
* @param int $durationMs Processing duration in milliseconds
* @param int|null $responseCode HTTP response code if applicable
* @param string|null $responseBody Response body if applicable
* @return void
*/
public function logSuccess(
ContentWebhookLog $webhookLog,
int $durationMs,
?int $responseCode = null,
?string $responseBody = null
): void {
$webhookLog->markCompletedWithDetails(
$durationMs,
$responseCode,
$responseBody
);
Log::info('Webhook delivery successful', [
'log_id' => $webhookLog->id,
'endpoint_id' => $webhookLog->endpoint_id,
'event_type' => $webhookLog->event_type,
'workspace_id' => $webhookLog->workspace_id,
'duration_ms' => $durationMs,
'response_code' => $responseCode,
]);
}
/**
* Log a failed webhook delivery with full details.
*
* @param string $error Error message
* @param int $durationMs Processing duration in milliseconds
* @param int|null $responseCode HTTP response code if applicable
* @param string|null $responseBody Response body if applicable
* @return void
*/
public function logFailure(
ContentWebhookLog $webhookLog,
string $error,
int $durationMs,
?int $responseCode = null,
?string $responseBody = null
): void {
$webhookLog->markFailedWithDetails(
$error,
$durationMs,
$responseCode,
$responseBody
);
Log::warning('Webhook delivery failed', [
'log_id' => $webhookLog->id,
'endpoint_id' => $webhookLog->endpoint_id,
'event_type' => $webhookLog->event_type,
'workspace_id' => $webhookLog->workspace_id,
'error' => $error,
'duration_ms' => $durationMs,
'response_code' => $responseCode,
]);
}
/**
* Log a signature verification failure for security auditing.
*
* Creates a log entry specifically for tracking failed signature
* verification attempts, which is critical for detecting potential
* attacks or misconfigured integrations.
*
* @param Request $request The incoming request
* @param ContentWebhookEndpoint $endpoint The webhook endpoint
* @param string $failureReason Reason for signature failure
* @return ContentWebhookLog The created log entry
*/
public function logSignatureFailure(
Request $request,
ContentWebhookEndpoint $endpoint,
string $failureReason
): ContentWebhookLog {
$log = ContentWebhookLog::create([
'workspace_id' => $endpoint->workspace_id,
'endpoint_id' => $endpoint->id,
'event_type' => 'signature_verification_failed',
'payload' => null, // Don't store potentially malicious payload
'request_headers' => $this->extractSafeHeaders($request),
'status' => 'failed',
'source_ip' => $request->ip(),
'signature_verified' => false,
'signature_failure_reason' => $failureReason,
'error_message' => 'Signature verification failed: ' . $failureReason,
'processed_at' => now(),
]);
Log::warning('Webhook signature verification failed', [
'log_id' => $log->id,
'endpoint_id' => $endpoint->id,
'endpoint_uuid' => $endpoint->uuid,
'source_ip' => $request->ip(),
'failure_reason' => $failureReason,
'require_signature' => $endpoint->requiresSignature(),
]);
return $log;
}
/**
* Log a successful signature verification.
*
* @param ContentWebhookLog $webhookLog The webhook log entry
* @param string $verificationMethod How the signature was verified (e.g., 'current_secret', 'grace_period')
* @return void
*/
public function logSignatureSuccess(
ContentWebhookLog $webhookLog,
string $verificationMethod
): void {
$webhookLog->recordSignatureVerification(true, $verificationMethod);
Log::debug('Webhook signature verified', [
'log_id' => $webhookLog->id,
'endpoint_id' => $webhookLog->endpoint_id,
'method' => $verificationMethod,
]);
}
/**
* Log when signature verification is bypassed (not required).
*
* This is an important security audit event - we track when webhooks
* are accepted without signature verification.
*
* @param Request $request The incoming request
* @param ContentWebhookEndpoint $endpoint The webhook endpoint
* @return void
*/
public function logSignatureNotRequired(
Request $request,
ContentWebhookEndpoint $endpoint
): void {
Log::warning('Webhook accepted without signature verification (explicitly disabled)', [
'endpoint_id' => $endpoint->id,
'endpoint_uuid' => $endpoint->uuid,
'endpoint_name' => $endpoint->name,
'source_ip' => $request->ip(),
'workspace_id' => $endpoint->workspace_id,
]);
}
/**
* Create a new webhook log entry for an incoming request.
*
* @param Request $request The incoming request
* @param ContentWebhookEndpoint $endpoint The webhook endpoint
* @param array $payload The parsed payload
* @param string $eventType The determined event type
* @param array $verificationResult The signature verification result
* @return ContentWebhookLog
*/
public function createDeliveryLog(
Request $request,
ContentWebhookEndpoint $endpoint,
array $payload,
string $eventType,
array $verificationResult
): ContentWebhookLog {
return ContentWebhookLog::create([
'workspace_id' => $endpoint->workspace_id,
'endpoint_id' => $endpoint->id,
'event_type' => $eventType,
'wp_id' => $this->extractContentId($payload),
'content_type' => $this->extractContentType($payload),
'payload' => $payload,
'request_headers' => $this->extractSafeHeaders($request),
'status' => 'pending',
'source_ip' => $request->ip(),
'signature_verified' => $verificationResult['verified'],
'signature_failure_reason' => $verificationResult['verified'] ? null : $verificationResult['reason'],
]);
}
/**
* Record processing metrics for a webhook.
*
* @param ContentWebhookLog $webhookLog The webhook log entry
* @param int $durationMs Processing duration in milliseconds
* @param array|null $result Processing result details
* @return void
*/
public function recordProcessingMetrics(
ContentWebhookLog $webhookLog,
int $durationMs,
?array $result = null
): void {
$webhookLog->recordProcessingComplete($durationMs);
Log::info('Webhook processing metrics recorded', [
'log_id' => $webhookLog->id,
'duration_ms' => $durationMs,
'result_action' => $result['action'] ?? 'unknown',
]);
}
/**
* Get delivery statistics for a workspace or endpoint.
*
* @param int|null $workspaceId Filter by workspace (null for all)
* @param int|null $endpointId Filter by endpoint (null for all)
* @param int $days Number of days to look back
* @return array{
* total: int,
* successful: int,
* failed: int,
* pending: int,
* signature_failures: int,
* avg_duration_ms: float|null,
* success_rate: float
* }
*/
public function getDeliveryStats(
?int $workspaceId = null,
?int $endpointId = null,
int $days = 30
): array {
$query = ContentWebhookLog::query()
->where('created_at', '>=', now()->subDays($days));
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
if ($endpointId !== null) {
$query->where('endpoint_id', $endpointId);
}
$total = (clone $query)->count();
$successful = (clone $query)->where('status', 'completed')->count();
$failed = (clone $query)->where('status', 'failed')->count();
$pending = (clone $query)->where('status', 'pending')->count();
$signatureFailures = (clone $query)->where('signature_verified', false)->count();
$avgDuration = (clone $query)
->whereNotNull('processing_duration_ms')
->avg('processing_duration_ms');
return [
'total' => $total,
'successful' => $successful,
'failed' => $failed,
'pending' => $pending,
'signature_failures' => $signatureFailures,
'avg_duration_ms' => $avgDuration !== null ? round((float) $avgDuration, 2) : null,
'success_rate' => $total > 0 ? round(($successful / $total) * 100, 2) : 100.0,
];
}
/**
* Get recent signature verification failures for security monitoring.
*
* @param int|null $workspaceId Filter by workspace
* @param int $limit Maximum number of failures to return
* @return \Illuminate\Database\Eloquent\Collection<ContentWebhookLog>
*/
public function getRecentSignatureFailures(
?int $workspaceId = null,
int $limit = 50
): \Illuminate\Database\Eloquent\Collection {
$query = ContentWebhookLog::query()
->where('signature_verified', false)
->orderBy('created_at', 'desc')
->limit($limit);
if ($workspaceId !== null) {
$query->where('workspace_id', $workspaceId);
}
return $query->get();
}
/**
* Extract headers that are safe to log.
*
* @param Request $request The incoming request
* @return array<string, string>
*/
public function extractSafeHeaders(Request $request): array
{
$headers = [];
foreach (self::SAFE_HEADERS as $header) {
$value = $request->header($header);
if ($value !== null) {
$headers[$header] = $value;
}
}
return $headers;
}
/**
* Extract content ID from payload.
*
* @param array $data The webhook payload
* @return int|null
*/
protected function extractContentId(array $data): ?int
{
$idFields = ['post_id', 'ID', 'id', 'content_id', 'item_id'];
foreach ($idFields as $field) {
if (isset($data[$field])) {
return (int) $data[$field];
}
if (isset($data['data'][$field])) {
return (int) $data['data'][$field];
}
}
return null;
}
/**
* Extract content type from payload.
*
* @param array $data The webhook payload
* @return string|null
*/
protected function extractContentType(array $data): ?string
{
$typeFields = ['post_type', 'content_type', 'type'];
foreach ($typeFields as $field) {
if (isset($data[$field])) {
return (string) $data[$field];
}
if (isset($data['data'][$field])) {
return (string) $data['data'][$field];
}
}
return null;
}
}

36
TODO.md
View file

@ -76,11 +76,12 @@ Production quality improvements for the Content Module.
- **Acceptance:** 80%+ coverage on AIGatewayService with edge case tests.
### TEST-002: Add tests for webhook signature verification
- **Status:** Open
- **Status:** Fixed
- **Description:** `ContentWebhookEndpoint::verifySignature()` handles multiple formats but isn't fully tested.
- **File:** `Models/ContentWebhookEndpoint.php:204-237`
- **Fix:** Add unit tests for each signature format and grace period behaviour.
- **Acceptance:** Tests cover: sha256= prefix, grace period rotation, empty signature handling.
- **Resolution:** (2026-01-29) See P2-082 fix. Added comprehensive feature tests in `tests/Feature/WebhookSignatureVerificationTest.php` covering all signature formats, grace period rotation, and various failure scenarios.
### PERF-001: Add database index for content search
- **Status:** Open
@ -289,6 +290,39 @@ Production quality improvements for the Content Module.
## Completed
### P2-082: Webhook Signature Verification (2026-01-29)
- **Description:** Signature verification was present but not comprehensively tested or centrally logged.
- **Resolution:**
- Created `Services/WebhookDeliveryLogger.php` service for centralised signature verification logging
- Updated `Controllers/Api/ContentWebhookController.php` to use the new service
- Added comprehensive feature tests in `tests/Feature/WebhookSignatureVerificationTest.php`:
- Tests for all signature header formats (X-Signature, X-Hub-Signature-256, X-WP-Webhook-Signature)
- Tests for grace period during secret rotation
- Tests for require_signature configuration
- Tests for signature failure audit logging
- Tests for timing-safe comparison verification
- Failed signature attempts are logged without storing potentially malicious payloads
- Security audit trail for all signature verification outcomes
### P2-083: Webhook Delivery Logging (2026-01-29)
- **Description:** Webhook delivery lacked comprehensive logging for debugging and audit purposes.
- **Resolution:**
- Created `Services/WebhookDeliveryLogger.php` service with:
- `logSuccess()` - logs successful deliveries with duration metrics
- `logFailure()` - logs failed deliveries with error details
- `logSignatureFailure()` - creates audit entries for signature failures
- `logSignatureNotRequired()` - warns when verification is bypassed
- `createDeliveryLog()` - creates delivery log entries with full request details
- `getDeliveryStats()` - retrieves delivery statistics for dashboards
- `getRecentSignatureFailures()` - retrieves recent failures for security monitoring
- Added comprehensive unit tests in `tests/Unit/WebhookDeliveryLoggerTest.php`
- Migration `2026_01_29_000001_add_signature_verification_fields.php` adds:
- `signature_verified` - boolean tracking verification success
- `signature_failure_reason` - stores failure reason for audit
- `processing_duration_ms` - tracks processing time
- `request_headers` - stores safe headers for debugging
- `response_code` and `response_body` - tracks response details
### SEC-002: HTML sanitisation fallback vulnerability (2026-01-29)
- Created `Services/HtmlSanitiser.php` using HTMLPurifier
- Added `ezyang/htmlpurifier` as required dependency in composer.json

View file

@ -0,0 +1,535 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Tests\Feature;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Feature tests for webhook signature verification.
*
* P2-082: Tests webhook signature verification flow
* P2-083: Tests delivery logging through the controller
*/
class WebhookSignatureVerificationTest extends TestCase
{
use RefreshDatabase;
protected Workspace $workspace;
protected ContentWebhookEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
$this->endpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
'secret' => 'test-webhook-secret-key',
'require_signature' => true,
'is_enabled' => true,
]);
}
// -------------------------------------------------------------------------
// Signature Verification Success Cases
// -------------------------------------------------------------------------
#[Test]
public function it_accepts_webhook_with_valid_signature(): void
{
$payload = json_encode(['ID' => 123, 'post_title' => 'Test Post', 'post_type' => 'post']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(202);
// Verify log was created with signature verified
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'wordpress.post_created')
->first();
$this->assertNotNull($log);
$this->assertTrue($log->signature_verified);
$this->assertNull($log->signature_failure_reason);
}
#[Test]
public function it_accepts_github_style_signature(): void
{
$payload = json_encode(['ID' => 456, 'post_title' => 'GitHub Style']);
$signature = 'sha256=' . hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Hub-Signature-256' => $signature,
'X-Event-Type' => 'post.updated',
]
);
$response->assertStatus(202);
}
#[Test]
public function it_accepts_wordpress_webhook_signature_header(): void
{
$payload = json_encode(['ID' => 789, 'post_title' => 'WordPress Style']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-WP-Webhook-Signature' => $signature,
'X-WP-Webhook-Event' => 'post_created',
]
);
$response->assertStatus(202);
}
// -------------------------------------------------------------------------
// Signature Verification Failure Cases
// -------------------------------------------------------------------------
#[Test]
public function it_rejects_webhook_with_missing_signature(): void
{
$payload = ['ID' => 123, 'post_title' => 'No Signature'];
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
$payload,
['X-Event-Type' => 'post.created']
);
$response->assertStatus(401);
// Verify failure was logged
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'signature_verification_failed')
->first();
$this->assertNotNull($log);
$this->assertFalse($log->signature_verified);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_MISSING, $log->signature_failure_reason);
}
#[Test]
public function it_rejects_webhook_with_invalid_signature(): void
{
$payload = ['ID' => 123, 'post_title' => 'Invalid Signature'];
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
$payload,
[
'X-Signature' => 'completely-wrong-signature',
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(401);
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'signature_verification_failed')
->first();
$this->assertNotNull($log);
$this->assertFalse($log->signature_verified);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_INVALID, $log->signature_failure_reason);
}
#[Test]
public function it_rejects_webhook_with_tampered_payload(): void
{
$originalPayload = json_encode(['ID' => 123, 'post_title' => 'Original']);
$signature = hash_hmac('sha256', $originalPayload, 'test-webhook-secret-key');
// Send tampered payload with original signature
$tamperedPayload = ['ID' => 123, 'post_title' => 'Tampered Content'];
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
$tamperedPayload,
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(401);
}
// -------------------------------------------------------------------------
// require_signature Configuration Tests
// -------------------------------------------------------------------------
#[Test]
public function it_rejects_unsigned_webhook_when_signature_required_but_no_secret(): void
{
// Endpoint with require_signature=true but no secret configured
$endpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
'secret' => null, // No secret
'require_signature' => true,
'is_enabled' => true,
]);
$response = $this->postJson(
"/api/content/webhooks/{$endpoint->uuid}",
['ID' => 123],
['X-Event-Type' => 'post.created']
);
$response->assertStatus(401);
$log = ContentWebhookLog::where('endpoint_id', $endpoint->id)
->where('event_type', 'signature_verification_failed')
->first();
$this->assertNotNull($log);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_NO_SECRET, $log->signature_failure_reason);
}
#[Test]
public function it_accepts_unsigned_webhook_when_signature_not_required(): void
{
// Endpoint with signature verification explicitly disabled
$unsignedEndpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
'secret' => null,
'require_signature' => false,
'is_enabled' => true,
]);
$response = $this->postJson(
"/api/content/webhooks/{$unsignedEndpoint->uuid}",
['ID' => 123, 'post_title' => 'Unsigned OK'],
['X-Event-Type' => 'post.created']
);
$response->assertStatus(202);
$log = ContentWebhookLog::where('endpoint_id', $unsignedEndpoint->id)
->where('event_type', 'wordpress.post_created')
->first();
$this->assertNotNull($log);
$this->assertTrue($log->signature_verified);
}
// -------------------------------------------------------------------------
// Secret Rotation Grace Period Tests
// -------------------------------------------------------------------------
#[Test]
public function it_accepts_signature_with_previous_secret_during_grace_period(): void
{
$oldSecret = 'old-secret-key';
$newSecret = 'new-secret-key';
// Set up endpoint in grace period
$this->endpoint->update([
'secret' => $newSecret,
'previous_secret' => $oldSecret,
'secret_rotated_at' => now(),
'grace_period_seconds' => 86400, // 24 hours
]);
// Sign with old secret
$payload = json_encode(['ID' => 123, 'post_title' => 'Grace Period Test']);
$signature = hash_hmac('sha256', $payload, $oldSecret);
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(202);
}
#[Test]
public function it_rejects_old_secret_after_grace_period_expires(): void
{
$oldSecret = 'old-secret-key';
$newSecret = 'new-secret-key';
// Set up endpoint with expired grace period
$this->endpoint->update([
'secret' => $newSecret,
'previous_secret' => $oldSecret,
'secret_rotated_at' => now()->subDays(2), // 2 days ago
'grace_period_seconds' => 86400, // 24 hour grace period (expired)
]);
// Sign with old secret
$payload = json_encode(['ID' => 123, 'post_title' => 'Expired Grace']);
$signature = hash_hmac('sha256', $payload, $oldSecret);
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(401);
}
#[Test]
public function it_accepts_new_secret_during_grace_period(): void
{
$oldSecret = 'old-secret-key';
$newSecret = 'new-secret-key';
// Set up endpoint in grace period
$this->endpoint->update([
'secret' => $newSecret,
'previous_secret' => $oldSecret,
'secret_rotated_at' => now(),
'grace_period_seconds' => 86400,
]);
// Sign with new secret
$payload = json_encode(['ID' => 123, 'post_title' => 'New Secret Test']);
$signature = hash_hmac('sha256', $payload, $newSecret);
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(202);
}
// -------------------------------------------------------------------------
// Delivery Logging Tests
// -------------------------------------------------------------------------
#[Test]
public function it_logs_request_headers(): void
{
$payload = json_encode(['ID' => 123, 'post_title' => 'Header Test']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
[
'X-Signature' => $signature,
'X-Event-Type' => 'post.created',
'User-Agent' => 'WordPress/6.4',
'X-Request-Id' => 'req-abc-123',
]
);
$response->assertStatus(202);
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'wordpress.post_created')
->first();
$this->assertNotNull($log->request_headers);
$this->assertIsArray($log->request_headers);
$this->assertArrayHasKey('User-Agent', $log->request_headers);
$this->assertArrayHasKey('X-Event-Type', $log->request_headers);
}
#[Test]
public function it_logs_source_ip(): void
{
$payload = json_encode(['ID' => 123, 'post_title' => 'IP Test']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => $signature, 'X-Event-Type' => 'post.created']
);
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'wordpress.post_created')
->first();
$this->assertNotNull($log->source_ip);
}
// -------------------------------------------------------------------------
// Endpoint State Tests
// -------------------------------------------------------------------------
#[Test]
public function it_rejects_webhook_for_disabled_endpoint(): void
{
$this->endpoint->update(['is_enabled' => false]);
$payload = json_encode(['ID' => 123, 'post_title' => 'Disabled']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => $signature, 'X-Event-Type' => 'post.created']
);
$response->assertStatus(403);
$response->assertSee('Endpoint disabled');
}
#[Test]
public function it_rejects_webhook_when_circuit_breaker_is_open(): void
{
$this->endpoint->update([
'failure_count' => ContentWebhookEndpoint::MAX_FAILURES,
]);
$payload = json_encode(['ID' => 123, 'post_title' => 'Circuit Open']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => $signature, 'X-Event-Type' => 'post.created']
);
$response->assertStatus(503);
$response->assertSee('Service unavailable');
}
#[Test]
public function it_rejects_disallowed_event_types(): void
{
$this->endpoint->update([
'allowed_types' => ['wordpress.post_created', 'wordpress.post_updated'],
]);
$payload = json_encode(['ID' => 123, 'post_title' => 'Delete Event']);
$signature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => $signature, 'X-Event-Type' => 'post.deleted']
);
$response->assertStatus(403);
$response->assertSee('Event type not allowed');
}
#[Test]
public function it_rejects_invalid_json_payload(): void
{
$invalidJson = '{invalid json';
$signature = hash_hmac('sha256', $invalidJson, 'test-webhook-secret-key');
$response = $this->call(
'POST',
"/api/content/webhooks/{$this->endpoint->uuid}",
[],
[],
[],
[
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => $signature,
'HTTP_X_EVENT_TYPE' => 'post.created',
],
$invalidJson
);
$response->assertStatus(400);
$response->assertSee('Invalid JSON payload');
}
// -------------------------------------------------------------------------
// Security Audit Tests
// -------------------------------------------------------------------------
#[Test]
public function it_does_not_store_payload_for_failed_signatures(): void
{
$sensitivePayload = [
'ID' => 123,
'post_title' => 'Sensitive Data',
'api_key' => 'secret-api-key-123',
'password' => 'super-secret-password',
];
$response = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
$sensitivePayload,
[
'X-Signature' => 'invalid-signature',
'X-Event-Type' => 'post.created',
]
);
$response->assertStatus(401);
$log = ContentWebhookLog::where('endpoint_id', $this->endpoint->id)
->where('event_type', 'signature_verification_failed')
->first();
// Payload should NOT be stored for security
$this->assertNull($log->payload);
}
#[Test]
public function it_uses_timing_safe_comparison_for_signatures(): void
{
// This test verifies that we're using hash_equals() internally
// by checking that both valid and invalid signatures take similar time
// We can't directly test timing, but we verify the signature verification
// uses the constant-time comparison method
$payload = json_encode(['ID' => 123]);
$validSignature = hash_hmac('sha256', $payload, 'test-webhook-secret-key');
// Valid signature
$response1 = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => $validSignature, 'X-Event-Type' => 'post.created']
);
$response1->assertStatus(202);
// Invalid signature (should be compared using hash_equals)
$response2 = $this->postJson(
"/api/content/webhooks/{$this->endpoint->uuid}",
json_decode($payload, true),
['X-Signature' => 'a' . substr($validSignature, 1), 'X-Event-Type' => 'post.updated']
);
$response2->assertStatus(401);
// The implementation uses hash_equals() which is timing-safe
// This is verified by code inspection in ContentWebhookEndpoint::verifySignatureWithDetails()
}
}

View file

@ -71,12 +71,83 @@ class ContentWebhookEndpointTest extends TestCase
}
#[Test]
public function it_allows_webhook_without_secret(): void
public function it_rejects_webhook_without_secret_when_signature_required(): void
{
$endpoint = ContentWebhookEndpoint::factory()->noSecret()->create();
$endpoint = ContentWebhookEndpoint::factory()->create([
'secret' => null,
'require_signature' => true,
]);
// When no secret, verification should pass
$this->assertTrue($endpoint->verifySignature('any payload', null));
// When signature is required but no secret configured, verification should fail
$result = $endpoint->verifySignatureWithDetails('any payload', null);
$this->assertFalse($result['verified']);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_NO_SECRET, $result['reason']);
}
#[Test]
public function it_allows_webhook_without_secret_when_signature_not_required(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
'secret' => null,
'require_signature' => false,
]);
// When signature is not required and no secret, verification should pass
$result = $endpoint->verifySignatureWithDetails('any payload', null);
$this->assertTrue($result['verified']);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_SUCCESS_NOT_REQUIRED, $result['reason']);
}
#[Test]
public function it_provides_detailed_verification_result_on_success(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
'secret' => 'test-secret-key',
]);
$payload = '{"event": "test"}';
$signature = hash_hmac('sha256', $payload, 'test-secret-key');
$result = $endpoint->verifySignatureWithDetails($payload, $signature);
$this->assertTrue($result['verified']);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_SUCCESS, $result['reason']);
}
#[Test]
public function it_provides_detailed_verification_result_on_missing_signature(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
'secret' => 'test-secret-key',
]);
$result = $endpoint->verifySignatureWithDetails('{"event": "test"}', null);
$this->assertFalse($result['verified']);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_MISSING, $result['reason']);
}
#[Test]
public function it_provides_detailed_verification_result_on_invalid_signature(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create([
'secret' => 'test-secret-key',
]);
$result = $endpoint->verifySignatureWithDetails('{"event": "test"}', 'invalid-signature');
$this->assertFalse($result['verified']);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_INVALID, $result['reason']);
}
#[Test]
public function it_defaults_require_signature_to_true(): void
{
$endpoint = ContentWebhookEndpoint::factory()->create();
$this->assertTrue($endpoint->requiresSignature());
}
#[Test]

View file

@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Content\Tests\Unit;
use Core\Mod\Content\Models\ContentWebhookEndpoint;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Mod\Content\Services\WebhookDeliveryLogger;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Tests for WebhookDeliveryLogger service.
*
* P2-082: Tests for signature verification logging
* P2-083: Tests for comprehensive delivery logging
*/
class WebhookDeliveryLoggerTest extends TestCase
{
protected WebhookDeliveryLogger $logger;
protected Workspace $workspace;
protected ContentWebhookEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
$this->logger = new WebhookDeliveryLogger();
$this->workspace = Workspace::factory()->create();
$this->endpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
'secret' => 'test-secret-key',
'require_signature' => true,
]);
}
// -------------------------------------------------------------------------
// P2-083: Delivery Logging Tests
// -------------------------------------------------------------------------
#[Test]
public function it_logs_successful_delivery_with_full_details(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'pending',
]);
$this->logger->logSuccess(
$webhookLog,
durationMs: 150,
responseCode: 200,
responseBody: '{"status": "ok"}'
);
$webhookLog->refresh();
$this->assertEquals('completed', $webhookLog->status);
$this->assertEquals(150, $webhookLog->processing_duration_ms);
$this->assertEquals(200, $webhookLog->response_code);
$this->assertEquals('{"status": "ok"}', $webhookLog->response_body);
$this->assertNotNull($webhookLog->processed_at);
$this->assertNull($webhookLog->error_message);
}
#[Test]
public function it_logs_failed_delivery_with_full_details(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'pending',
]);
$this->logger->logFailure(
$webhookLog,
error: 'Connection timeout',
durationMs: 30000,
responseCode: 504,
responseBody: 'Gateway Timeout'
);
$webhookLog->refresh();
$this->assertEquals('failed', $webhookLog->status);
$this->assertEquals(30000, $webhookLog->processing_duration_ms);
$this->assertEquals(504, $webhookLog->response_code);
$this->assertEquals('Gateway Timeout', $webhookLog->response_body);
$this->assertStringContainsString('Connection timeout', $webhookLog->error_message);
$this->assertNotNull($webhookLog->processed_at);
}
#[Test]
public function it_truncates_long_response_bodies(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'pending',
]);
// Create a very long response body (> 10000 chars)
$longBody = str_repeat('x', 15000);
$this->logger->logSuccess(
$webhookLog,
durationMs: 100,
responseCode: 200,
responseBody: $longBody
);
$webhookLog->refresh();
// Response body should be truncated to 10000 chars
$this->assertLessThanOrEqual(10000, strlen($webhookLog->response_body ?? ''));
}
#[Test]
public function it_creates_delivery_log_with_all_fields(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST', [], [], [], [
'HTTP_CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'WordPress/6.4',
'HTTP_X_EVENT_TYPE' => 'post.created',
], '{"ID": 123, "post_type": "post"}');
$payload = ['ID' => 123, 'post_type' => 'post', 'post_title' => 'Test'];
$verificationResult = ['verified' => true, 'reason' => 'verified'];
$log = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
$payload,
'wordpress.post_created',
$verificationResult
);
$this->assertEquals($this->workspace->id, $log->workspace_id);
$this->assertEquals($this->endpoint->id, $log->endpoint_id);
$this->assertEquals('wordpress.post_created', $log->event_type);
$this->assertEquals(123, $log->wp_id);
$this->assertEquals('post', $log->content_type);
$this->assertEquals($payload, $log->payload);
$this->assertEquals('pending', $log->status);
$this->assertTrue($log->signature_verified);
$this->assertNull($log->signature_failure_reason);
$this->assertIsArray($log->request_headers);
}
#[Test]
public function it_extracts_safe_headers_only(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST', [], [], [], [
'HTTP_CONTENT_TYPE' => 'application/json',
'HTTP_USER_AGENT' => 'WordPress/6.4',
'HTTP_X_SIGNATURE' => 'sha256=secret_signature_value',
'HTTP_AUTHORIZATION' => 'Bearer secret_token',
'HTTP_X_API_KEY' => 'super_secret_key',
'HTTP_X_EVENT_TYPE' => 'post.created',
]);
$headers = $this->logger->extractSafeHeaders($request);
// Safe headers should be present
$this->assertArrayHasKey('Content-Type', $headers);
$this->assertArrayHasKey('User-Agent', $headers);
$this->assertArrayHasKey('X-Event-Type', $headers);
// Sensitive headers should NOT be present
$this->assertArrayNotHasKey('X-Signature', $headers);
$this->assertArrayNotHasKey('Authorization', $headers);
$this->assertArrayNotHasKey('X-Api-Key', $headers);
}
#[Test]
public function it_records_processing_metrics(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'processing',
]);
$this->logger->recordProcessingMetrics(
$webhookLog,
durationMs: 250,
result: ['action' => 'created', 'content_item_id' => 456]
);
$webhookLog->refresh();
$this->assertEquals(250, $webhookLog->processing_duration_ms);
}
#[Test]
public function it_calculates_delivery_statistics(): void
{
// Create some webhook logs with various statuses
ContentWebhookLog::factory()->count(5)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'completed',
'signature_verified' => true,
'processing_duration_ms' => 100,
]);
ContentWebhookLog::factory()->count(2)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'failed',
'signature_verified' => true,
'processing_duration_ms' => 200,
]);
ContentWebhookLog::factory()->count(1)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'failed',
'signature_verified' => false,
'signature_failure_reason' => 'signature_invalid',
]);
ContentWebhookLog::factory()->count(2)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'pending',
'signature_verified' => true,
]);
$stats = $this->logger->getDeliveryStats($this->workspace->id);
$this->assertEquals(10, $stats['total']);
$this->assertEquals(5, $stats['successful']);
$this->assertEquals(3, $stats['failed']);
$this->assertEquals(2, $stats['pending']);
$this->assertEquals(1, $stats['signature_failures']);
$this->assertEquals(50.0, $stats['success_rate']);
$this->assertNotNull($stats['avg_duration_ms']);
}
#[Test]
public function it_calculates_statistics_for_specific_endpoint(): void
{
// Create another endpoint
$otherEndpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
]);
ContentWebhookLog::factory()->count(3)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'completed',
]);
ContentWebhookLog::factory()->count(2)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $otherEndpoint->id,
'status' => 'completed',
]);
$stats = $this->logger->getDeliveryStats(
workspaceId: $this->workspace->id,
endpointId: $this->endpoint->id
);
$this->assertEquals(3, $stats['total']);
}
// -------------------------------------------------------------------------
// P2-082: Signature Verification Logging Tests
// -------------------------------------------------------------------------
#[Test]
public function it_logs_signature_verification_failure(): void
{
Log::spy();
$request = Request::create('/api/content/webhooks/test', 'POST', [], [], [], [
'HTTP_CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => 'invalid_signature',
]);
$log = $this->logger->logSignatureFailure(
$request,
$this->endpoint,
ContentWebhookEndpoint::SIGNATURE_FAILURE_INVALID
);
$this->assertEquals($this->workspace->id, $log->workspace_id);
$this->assertEquals($this->endpoint->id, $log->endpoint_id);
$this->assertEquals('signature_verification_failed', $log->event_type);
$this->assertEquals('failed', $log->status);
$this->assertFalse($log->signature_verified);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_INVALID, $log->signature_failure_reason);
$this->assertNull($log->payload); // Payload should not be stored for failed signatures
$this->assertNotNull($log->processed_at);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'signature verification failed'));
}
#[Test]
public function it_logs_signature_success(): void
{
Log::spy();
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'signature_verified' => null,
]);
$this->logger->logSignatureSuccess(
$webhookLog,
ContentWebhookEndpoint::SIGNATURE_SUCCESS
);
$webhookLog->refresh();
$this->assertTrue($webhookLog->signature_verified);
$this->assertNull($webhookLog->signature_failure_reason);
}
#[Test]
public function it_logs_signature_success_during_grace_period(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'signature_verified' => null,
]);
$this->logger->logSignatureSuccess(
$webhookLog,
ContentWebhookEndpoint::SIGNATURE_SUCCESS_GRACE
);
$webhookLog->refresh();
$this->assertTrue($webhookLog->signature_verified);
}
#[Test]
public function it_logs_when_signature_not_required(): void
{
Log::spy();
$unsignedEndpoint = ContentWebhookEndpoint::factory()->create([
'workspace_id' => $this->workspace->id,
'secret' => null,
'require_signature' => false,
]);
$request = Request::create('/api/content/webhooks/test', 'POST');
$this->logger->logSignatureNotRequired($request, $unsignedEndpoint);
Log::shouldHaveReceived('warning')
->withArgs(fn ($message) => str_contains($message, 'without signature verification'));
}
#[Test]
public function it_retrieves_recent_signature_failures(): void
{
// Create some signature failures
ContentWebhookLog::factory()->count(3)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'signature_verified' => false,
'signature_failure_reason' => 'signature_invalid',
]);
// Create some successful verifications
ContentWebhookLog::factory()->count(2)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'signature_verified' => true,
]);
$failures = $this->logger->getRecentSignatureFailures($this->workspace->id);
$this->assertCount(3, $failures);
foreach ($failures as $failure) {
$this->assertFalse($failure->signature_verified);
}
}
#[Test]
public function it_limits_recent_signature_failures(): void
{
ContentWebhookLog::factory()->count(10)->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'signature_verified' => false,
]);
$failures = $this->logger->getRecentSignatureFailures(
workspaceId: $this->workspace->id,
limit: 5
);
$this->assertCount(5, $failures);
}
#[Test]
public function it_creates_delivery_log_with_failed_verification(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST');
$payload = ['ID' => 123, 'post_type' => 'post'];
$verificationResult = [
'verified' => false,
'reason' => ContentWebhookEndpoint::SIGNATURE_FAILURE_MISSING,
];
$log = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
$payload,
'wordpress.post_created',
$verificationResult
);
$this->assertFalse($log->signature_verified);
$this->assertEquals(ContentWebhookEndpoint::SIGNATURE_FAILURE_MISSING, $log->signature_failure_reason);
}
// -------------------------------------------------------------------------
// Content ID/Type Extraction Tests
// -------------------------------------------------------------------------
#[Test]
public function it_extracts_content_id_from_various_payload_formats(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST');
$verificationResult = ['verified' => true, 'reason' => 'verified'];
// WordPress format with ID
$log1 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['ID' => 123],
'wordpress.post_created',
$verificationResult
);
$this->assertEquals(123, $log1->wp_id);
// WordPress format with post_id
$log2 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['post_id' => 456],
'wordpress.post_created',
$verificationResult
);
$this->assertEquals(456, $log2->wp_id);
// Generic CMS format with nested data
$log3 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['data' => ['id' => 789]],
'cms.content_created',
$verificationResult
);
$this->assertEquals(789, $log3->wp_id);
}
#[Test]
public function it_extracts_content_type_from_various_payload_formats(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST');
$verificationResult = ['verified' => true, 'reason' => 'verified'];
// WordPress format
$log1 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['ID' => 1, 'post_type' => 'page'],
'wordpress.post_created',
$verificationResult
);
$this->assertEquals('page', $log1->content_type);
// Generic format
$log2 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['id' => 1, 'content_type' => 'article'],
'cms.content_created',
$verificationResult
);
$this->assertEquals('article', $log2->content_type);
// Nested format
$log3 = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
['data' => ['id' => 1, 'type' => 'blog']],
'cms.content_created',
$verificationResult
);
$this->assertEquals('blog', $log3->content_type);
}
// -------------------------------------------------------------------------
// Edge Cases
// -------------------------------------------------------------------------
#[Test]
public function it_handles_null_response_body(): void
{
$webhookLog = ContentWebhookLog::factory()->create([
'workspace_id' => $this->workspace->id,
'endpoint_id' => $this->endpoint->id,
'status' => 'pending',
]);
$this->logger->logSuccess(
$webhookLog,
durationMs: 100,
responseCode: 204, // No content
responseBody: null
);
$webhookLog->refresh();
$this->assertEquals('completed', $webhookLog->status);
$this->assertEquals(204, $webhookLog->response_code);
$this->assertNull($webhookLog->response_body);
}
#[Test]
public function it_handles_empty_payload(): void
{
$request = Request::create('/api/content/webhooks/test', 'POST');
$verificationResult = ['verified' => true, 'reason' => 'verified'];
$log = $this->logger->createDeliveryLog(
$request,
$this->endpoint,
[],
'generic.payload',
$verificationResult
);
$this->assertNull($log->wp_id);
$this->assertNull($log->content_type);
$this->assertEquals([], $log->payload);
}
#[Test]
public function it_returns_correct_success_rate_when_no_logs_exist(): void
{
// Create a new workspace with no logs
$emptyWorkspace = Workspace::factory()->create();
$stats = $this->logger->getDeliveryStats($emptyWorkspace->id);
$this->assertEquals(0, $stats['total']);
$this->assertEquals(100.0, $stats['success_rate']); // 100% when no failures
$this->assertNull($stats['avg_duration_ms']);
}
}