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:
parent
fa4893d064
commit
0120908669
10 changed files with 1918 additions and 98 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
385
Services/WebhookDeliveryLogger.php
Normal file
385
Services/WebhookDeliveryLogger.php
Normal 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
36
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
535
tests/Feature/WebhookSignatureVerificationTest.php
Normal file
535
tests/Feature/WebhookSignatureVerificationTest.php
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
571
tests/Unit/WebhookDeliveryLoggerTest.php
Normal file
571
tests/Unit/WebhookDeliveryLoggerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue