diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index e6ee02c7ee..193beb528b 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -43,7 +43,7 @@ class Kernel extends HttpKernel \Illuminate\Routing\Middleware\SubstituteBindings::class, Middleware\GenerateMenus::class, \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, - \ProcessMaker\Http\Middleware\IgnoreMapFiles::class, + Middleware\IgnoreMapFiles::class, ], 'api' => [ // API Middleware is defined with routeMiddleware below. @@ -85,6 +85,7 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, + 'etag' => Middleware\Etag\HandleEtag::class, ]; /** diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php new file mode 100644 index 0000000000..5425e0e6da --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -0,0 +1,169 @@ +isMethod('GET') && !$request->isMethod('HEAD')) { + return $next($request); + } + + // Check if specific tables are defined for the route and calculate ETag. + $etag = $this->generateEtagFromTablesIfNeeded($request); + + // If the client has a matching ETag, return a 304 response. + // Otherwise, continue with the controller execution. + $response = $etag && $this->etagMatchesRequest($etag, $request) + ? $this->buildNotModifiedResponse($etag) + : $next($request); + + // Add the pre-calculated ETag to the response if available. + if ($etag) { + $response = $this->setEtagOnResponse($response, $etag); + } + + // If no ETag was calculated from tables, generate it based on the response. + if (!$etag && $this->isCacheableResponse($response)) { + $etag = EtagManager::getEtag($request, $response); + if ($etag) { + $response = $this->setEtagOnResponse($response, $etag); + + // If the client has a matching ETag, set the response to 304. + if ($this->etagMatchesRequest($etag, $request)) { + $response = $this->buildNotModifiedResponse($etag); + } + } + } + + // Detect if the ETag changes frequently for dynamic responses. + $this->logEtagChanges($request, $etag); + + return $response; + } + + /** + * Determine if a response is cacheable. + */ + private function isCacheableResponse(Response $response): bool + { + $cacheableStatusCodes = [200, 203, 204, 206, 304]; + $cacheControl = $response->headers->get('Cache-Control', ''); + + // Verify if the status code is cacheable and does not contain "no-store". + return in_array($response->getStatusCode(), $cacheableStatusCodes) + && !str_contains($cacheControl, 'no-store'); + } + + /** + * Generate an ETag based on the tables defined in the route, if applicable. + */ + private function generateEtagFromTablesIfNeeded(Request $request): ?string + { + $tables = $request->route()->defaults['etag_tables'] ?? null; + + return $tables ? EtagManager::generateEtagFromTables(explode(',', $tables)) : null; + } + + /** + * Check if the ETag matches the request. + */ + private function etagMatchesRequest(string $etag, Request $request): bool + { + $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + + return in_array($etag, $noneMatch); + } + + /** + * Build a 304 Not Modified response with the given ETag. + */ + private function buildNotModifiedResponse(string $etag): Response + { + $response = new Response(); + $response->setNotModified(); + $response->setEtag($etag); + + return $response; + } + + /** + * Set the ETag on a given response. + */ + private function setEtagOnResponse(Response $response, string $etag): Response + { + $response->setEtag($etag); + + return $response; + } + + /** + * Remove the weak indicator (W/) from an ETag. + */ + private function stripWeakTags(string $etag): string + { + return str_replace('W/', '', $etag); + } + + /** + * Log ETag changes to detect highly dynamic responses. + */ + private function logEtagChanges(Request $request, ?string $etag): void + { + if (!config('etag.enabled') || !config('etag.log_dynamic_endpoints')) { + return; + } + + if (!$etag) { + return; + } + + // Retrieve the history of ETags for this endpoint. + $url = $request->fullUrl(); + $cacheKey = 'etag_history:' . md5($url); + $etagHistory = Cache::get($cacheKey, []); + + // If the ETag is already in the history, it is not considered dynamic. + if (in_array($etag, $etagHistory, true)) { + return; + } + + // Add the new ETag to the history. + $etagHistory[] = $etag; + + // Keep the history limited to the last n ETags. + $etagHistoryLimit = config('etag.history_limit', 10); + if (count($etagHistory) > $etagHistoryLimit) { + array_shift($etagHistory); // Remove the oldest ETag. + } + + // Save the updated history in the cache, valid for 30 minutes. + $cacheExpirationMinute = config('etag.history_cache_expiration'); + Cache::put($cacheKey, $etagHistory, now()->addMinutes($cacheExpirationMinute)); + + // If the history is full and all ETags are unique, log this as a highly dynamic endpoint. + if (count(array_unique($etagHistory)) === $etagHistoryLimit) { + Log::info('ETag Dynamic endpoint detected', [ + 'url' => $url, + ]); + } + } +} diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php new file mode 100644 index 0000000000..71d41ad950 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -0,0 +1,73 @@ +start('"')->finish('"'); + } + + /** + * Generate a default ETag, including user-specific data by default. + */ + private static function defaultGetEtag(Response $response): string + { + return md5(auth()->id() . $response->getContent()); + } + + /** + * Generate an ETag based on the latest update timestamps from multiple tables. + */ + public static function generateEtagFromTables(array $tables, string $source = 'updated_at'): string + { + // Fetch the latest update timestamp from each table. + // If the source is 'etag_version', use a cached version key as the source of truth. + $lastUpdated = collect($tables)->map(function ($table) use ($source) { + if ($source === 'etag_version') { + /** + * This is not currently implemented but serves as a placeholder for future flexibility. + * The idea is to use a cached version key (e.g., "etag_version_table_name") as the source of truth. + * This would allow us to version the ETag dynamically and invalidate it using model observers or other mechanisms. + * If implemented, observers can increment this version key whenever the corresponding table is updated. + */ + return Cache::get("etag_version_{$table}", 0); + } + + // Default to the updated_at column in the database. + return DB::table($table)->max('updated_at'); + })->max(); + + $etag = md5(auth()->id() . $lastUpdated); + + return (string) Str::of($etag)->start('"')->finish('"'); + } +} diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index c577942c8f..cec32ced7e 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -82,7 +82,6 @@ class TaskResource extends ApiResource 'is_administrator', 'expires_at', 'loggedin_at', - 'active_at', 'created_at', 'updated_at', 'delegation_user_id', diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index f4e2f9be92..b1fbb807ae 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; use Laravel\Dusk\DuskServiceProvider; use Laravel\Horizon\Horizon; @@ -21,6 +22,7 @@ use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ScreenBuilderStarting; use ProcessMaker\Helpers\PmHash; +use ProcessMaker\Http\Middleware\Etag\HandleEtag; use ProcessMaker\ImportExport\Extension; use ProcessMaker\ImportExport\SignalHelper; use ProcessMaker\Jobs\SmartInbox; @@ -39,10 +41,13 @@ class ProcessMakerServiceProvider extends ServiceProvider { // Track the start time for service providers boot private static $bootStart; + // Track the boot time for service providers private static $bootTime; + // Track the boot time for each package private static $packageBootTiming = []; + // Track the query time for each request private static $queryTime = 0; @@ -67,6 +72,7 @@ public function boot(): void parent::boot(); + Route::pushMiddlewareToGroup('api', HandleEtag::class); // Hook after service providers boot self::$bootTime = (microtime(true) - self::$bootStart) * 1000; // Convert to milliseconds } @@ -221,14 +227,14 @@ protected static function registerEvents(): void $notifiable = get_class($event->notifiable); $notification = get_class($event->notification); - Facades\Log::debug("Sent Notification to {$notifiable} #{$id}: {$notification}"); + Log::debug("Sent Notification to {$notifiable} #{$id}: {$notification}"); }); // Log Broadcasts (messages sent to laravel-echo-server and redis) Facades\Event::listen(BroadcastNotificationCreated::class, function ($event) { $channels = implode(', ', $event->broadcastOn()); - Facades\Log::debug('Broadcasting Notification ' . $event->broadcastType() . 'on channel(s) ' . $channels); + Log::debug('Broadcasting Notification ' . $event->broadcastType() . 'on channel(s) ' . $channels); }); // Fire job when task is assigned to a user diff --git a/ProcessMaker/Providers/RouteServiceProvider.php b/ProcessMaker/Providers/RouteServiceProvider.php index 1315214a1d..e12823a721 100644 --- a/ProcessMaker/Providers/RouteServiceProvider.php +++ b/ProcessMaker/Providers/RouteServiceProvider.php @@ -89,7 +89,7 @@ protected function mapApiRoutes() { Route::middleware('api') ->group(base_path('routes/api.php')); - Route::middleware('auth:api') + Route::middleware(['auth:api', 'etag']) ->group(base_path('routes/v1_1/api.php')); } diff --git a/ProcessMaker/Traits/TaskResourceIncludes.php b/ProcessMaker/Traits/TaskResourceIncludes.php index de2370f369..9d094ccad6 100644 --- a/ProcessMaker/Traits/TaskResourceIncludes.php +++ b/ProcessMaker/Traits/TaskResourceIncludes.php @@ -41,7 +41,12 @@ private function includeUser() private function includeRequestor() { - return ['requestor' => new Users($this->processRequest->user)]; + $user = $this->processRequest->user; + + // Exclude 'active_at' to prevent ETag inconsistencies. + $user->makeHidden(['active_at']); + + return ['requestor' => new Users($user)]; } private function includeProcessRequest() diff --git a/config/etag.php b/config/etag.php new file mode 100644 index 0000000000..b4e016f8db --- /dev/null +++ b/config/etag.php @@ -0,0 +1,47 @@ + env('ETAG_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Log Dynamic Endpoints + |-------------------------------------------------------------------------- + | + | Enable or disable logging when an endpoint is detected as dynamic. + | If set to false, no logs will be recorded for dynamic endpoints. + | + */ + 'log_dynamic_endpoints' => env('ETAG_LOG_DYNAMIC_ENDPOINTS', false), + + /* + |-------------------------------------------------------------------------- + | ETag History Limit + |-------------------------------------------------------------------------- + | + | The maximum number of ETags to track per endpoint. If the number of + | unique ETags exceeds this limit, the oldest ETag will be removed. + | + */ + 'history_limit' => env('ETAG_HISTORY_LIMIT', 10), + + /* + |-------------------------------------------------------------------------- + | History Cache Expiration Time + |-------------------------------------------------------------------------- + | + | The duration (in minutes) for which the ETag history should be stored + | in the cache. Adjust this based on your caching strategy. + | + */ + 'history_cache_expiration' => env('ETAG_HISTORY_CACHE_EXPIRATION_MINUTES', 30), +]; diff --git a/docs/etag-caching.md b/docs/etag-caching.md new file mode 100644 index 0000000000..4ee52a2549 --- /dev/null +++ b/docs/etag-caching.md @@ -0,0 +1,170 @@ +# ETag Documentation + +## Introduction + +### Description +An ETag (Entity Tag) is a unique fingerprint for a server response, similar to a hash based on the response content. If the content remains unchanged, the ETag stays the same. + +- **How it works**: + - When a browser requests content, it sends the previous ETag. + - If the server’s ETag matches, it responds with a `304 Not Modified` status, signaling no changes. + - This allows the browser to reuse its cached copy, saving time and bandwidth. + +### Purpose of Implementing ETag +The goal of implementing ETag is to enhance application performance by avoiding reloading unchanged content. The browser stores a copy, and if confirmed valid by the server, no new download is required. + +### Expected Benefits +- **Enhanced performance**: Reduces server load by avoiding processing unchanged content. +- **Bandwidth savings**: Cached content is reused. +- **Increased speed**: `304 Not Modified` responses are faster than resending content. +- **Resource optimization**: Lowers server CPU and memory consumption. + +### Middleware Overview +The `HandleEtag` middleware manages ETags in HTTP responses to optimize requests through conditional responses (`304 Not Modified`), reducing server load and bandwidth usage. + +## Implementation + +### Middleware Setup +The middleware is applied globally to API `GET` routes and can be customized for specific routes via table configurations (`etag_tables`). + +#### 1. HandleEtag Middleware +The `HandleEtag` middleware performs key tasks: +1. Applies ETag functionality to `GET` requests only. +2. Generates the ETag: + - Based on related tables if configured (optional). + - Based on the response content when no tables are specified (default). +3. Validates the client’s `If-None-Match` header. If it matches the calculated ETag, responds with a `304 Not Modified` status. +4. Ensures HTTP responses include the ETag header. + +#### 2. ETag Generation +ETag generation is handled dynamically via the `EtagManager` class, using two methods: + +**Based on Tables** +ETag is calculated using the `updated_at` timestamp of specified tables: +```php +public static function generateEtagFromTables(array $tables, string $source = 'updated_at'): string +{ + $lastUpdated = collect($tables)->map(function ($table) use ($source) { + return DB::table($table)->max($source); + })->max(); + + return md5(auth()->id() . $lastUpdated); +} +``` + +**Based on Response** +ETag is generated from the response content: +```php +public static function getEtag(Request $request, Response $response): string +{ + return md5(auth()->id() . $response->getContent()); +} +``` + +#### 3. Conditional Responses +The middleware handles conditional responses by checking the client’s `If-None-Match` header: +1. If the calculated ETag matches the client’s value, responds with `304 Not Modified`. +2. Otherwise, generates new content and includes a new ETag in the response. + +#### 4. Middleware Configuration +The `HandleEtag` middleware is registered globally within the API group: +```php +// ProcessMaker/Providers/ProcessMakerServiceProvider.php +public function boot(): void +{ + Route::pushMiddlewareToGroup('api', HandleEtag::class); +} +``` + +Routes in `v1_1` can be mapped as follows: +```php +protected function mapApiRoutes() +{ + Route::middleware(['auth:api', 'etag']) + ->group(base_path('routes/v1_1/api.php')); +} +``` + +#### 5. Route Usage +Routes can define custom settings, such as associating ETags with database tables: +```php +Route::prefix('api/1.0')->name('api.')->group(function () { + Route::get('start_processes', [ProcessController::class, 'startProcesses']) + ->middleware('etag') // Apply middleware + ->defaults('etag_tables', 'processes') // Define related tables + ->name('processes.start'); +}); +``` + +In this example: +- Middleware generates the ETag based on the last update of the `processes` table. +- If the client has the corresponding ETag, the server responds with `304 Not Modified` and does not execute the controller logic. + +## Config file + +The ETag functionality is managed through the `config/etag.php` file, which centralizes all related settings. This configuration file allows you to enable or disable ETag logging and caching, as well as customize key parameters such as history limits and cache expiration times. + +### Key Options +- **`enabled`**: Determines whether the ETag functionality is active. If set to `false`, all ETag-related processing is skipped. +- **`log_dynamic_endpoints`**: Controls whether dynamic endpoints are logged. When disabled, no cache processing occurs. +- **`history_limit`**: Specifies the maximum number of ETags to track per endpoint. +- **`cache_expiration`**: Sets the duration (in minutes) for which the ETag history is cached. + +## Logs + +This middleware detects **highly dynamic endpoints** by tracking the history of ETags generated for each endpoint. It helps identify endpoints where ETags are consistently different, indicating dynamic responses that may require further optimization. + +1. Tracks the last **N ETags** (default: 10) for each endpoint using Laravel's cache. +2. Logs endpoints as "highly dynamic" if all tracked ETags are unique. +3. Efficient caching and memory usage to minimize performance overhead. + +### Example Logs + +When an endpoint is identified as highly dynamic, the following log is generated: + +``` +ETag Dynamic endpoint detected: +{ + "url": "https://example.com/api/resource", +} +``` + +## Testing + +### Unit Tests +Unit tests ensure proper functionality of the `HandleEtag` middleware and `EtagManager` class: +- **Middleware Tests**: + - Ensures the `ETag` header is added correctly. + - Validates `If-None-Match` requests with appropriate status codes (`200 OK` or `304 Not Modified`). + - Handles weak ETags and generates custom ETags. +- **EtagManager Tests**: + - Validates ETag generation using: + - Default response content. + - Custom callbacks (e.g., `md5`, `sha256`). + - Dynamic data such as database tables. + +### Manual Tests +Use tools like Postman or browser developer tools for manual testing: +1. **Verify ETag header generation**: + - Make a `GET` request to an API route. + - Check the response headers for a valid ETag value. +2. **Validate `If-None-Match` behavior**: + - Send a `GET` request with the previous ETag. + - Verify response status (`304 Not Modified` if unchanged, `200 OK` if changed). + +### Example Scenarios +- Test routes using `etag_tables`: + - Change data in related tables and observe changes in the ETag header. + +## Conclusion + +This implementation leverages ETags to optimize `GET` requests in the API, reducing bandwidth usage and improving performance. Middleware flexibility allows ETag customization based on specific tables or response content. + +### Future Improvements +1. **ETag Versioning with Cache**: + - Store ETag versions in cache to avoid database queries. + - Enable manual invalidation via model events. +2. **Metrics Collection**: + - Monitor request duration, `304` response percentage and bandwidth savings. + +This solution is well-suited for global optimizations and specific dynamic routes. diff --git a/routes/engine.php b/routes/engine.php index 92f67def38..6ca0557b18 100644 --- a/routes/engine.php +++ b/routes/engine.php @@ -8,7 +8,10 @@ // Engine Route::prefix('api/1.0')->name('api.')->group(function () { // List of Processes that the user can start - Route::get('start_processes', [ProcessController::class, 'startProcesses'])->name('processes.start'); // Filtered in controller + Route::get('start_processes', [ProcessController::class, 'startProcesses']) + ->middleware('etag') + ->defaults('etag_tables', 'processes') + ->name('processes.start'); // Filtered in controller // Start a process Route::post('process_events/{process}', [ProcessController::class, 'triggerStartEvent'])->name('process_events.trigger')->middleware('can:start,process'); diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index a2b2328dc9..70ed572f5c 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -22,6 +22,7 @@ // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) + ->defaults('etag_tables', 'screens,screen_versions') ->name('show.screen'); // Route to show the interstitial screen of a task diff --git a/tests/Feature/Etag/EtagManagerTest.php b/tests/Feature/Etag/EtagManagerTest.php new file mode 100644 index 0000000000..010c28ebbb --- /dev/null +++ b/tests/Feature/Etag/EtagManagerTest.php @@ -0,0 +1,51 @@ +response, 200); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackMd5() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return md5($response->getContent()); + }); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackSha256() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return hash('sha256', $response->getContent()); + }); + + $expectedHash = hash('sha256', $this->response); + $this->assertEquals("\"$expectedHash\"", EtagManager::getEtag($request, $response)); + } +} diff --git a/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php b/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php new file mode 100644 index 0000000000..f9a3663c61 --- /dev/null +++ b/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php @@ -0,0 +1,65 @@ +get(self::TEST_ROUTE, function () { + return response($this->response, 200); + }) + ->defaults('etag_tables', 'processes'); + } + + public function testEtagInvalidatesOnDatabaseUpdate() + { + $user = User::factory()->create(); + $this->actingAs($user); + + // Create a process record to simulate database changes. + $process = Process::factory()->create([ + 'updated_at' => now()->yesterday(), + ]); + + // First request: Get the initial ETag. + $response = $this->get(self::TEST_ROUTE); + $initialEtag = $response->headers->get('ETag'); + $this->assertNotNull($initialEtag, 'Initial ETag was set'); + + // Simulate a database update by changing `updated_at`. + $process->update(['name' => $this->faker->name]); + + // Second request: ETag should change due to the database update. + $responseAfterUpdate = $this->get(self::TEST_ROUTE); + $newEtag = $responseAfterUpdate->headers->get('ETag'); + + $this->assertNotNull($newEtag, 'New ETag was set after database update'); + $this->assertNotEquals($initialEtag, $newEtag, 'ETag changed after database update'); + + // Third request: Simulate a client sending the old ETag. + $responseWithOldEtag = $this->withHeaders(['If-None-Match' => $initialEtag]) + ->get(self::TEST_ROUTE); + + $responseWithOldEtag->assertStatus(200); + $responseWithOldEtag->assertHeader('ETag', $newEtag, 'Response did not return the updated ETag'); + } +} diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php new file mode 100644 index 0000000000..af9c731e29 --- /dev/null +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -0,0 +1,114 @@ +any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + } + + public function testMiddlewareSetsEtagHeader() + { + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag'); + } + + public function testEtagHeaderHasCorrectValue() + { + $expectedEtag = '"' . md5($this->response) . '"'; + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag', $expectedEtag); + } + + public function testRequestReturns200WhenIfNoneMatchDoesNotMatch() + { + $noneMatch = '"' . md5($this->response . 'NoneMatch') . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $noneMatch]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + } + + public function testRequestReturns304WhenIfNoneMatchMatches() + { + $matchingEtag = '"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $matchingEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', $matchingEtag); + } + + public function testRequestIgnoresWeakEtagsInIfNoneMatch() + { + $weakEtag = 'W/"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $weakEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', '"' . md5($this->response) . '"'); + } + + public function testDefaultGetEtagGeneratesCorrectEtagWithUser() + { + $user = User::factory()->create(); + $this->actingAs($user); + + Route::middleware('etag')->any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + + $response = $this->get(self::TEST_ROUTE); + + $expectedEtag = '"' . md5($user->id . $this->response) . '"'; + $response->assertHeader('ETag', $expectedEtag); + } + + public function testReturns304NotModifiedWhenEtagMatchesTables() + { + $user = User::factory()->create(); + $this->actingAs($user); + + Process::factory()->create([ + 'updated_at' => now()->yesterday(), + ]); + + Route::middleware('etag')->any(self::TEST_ROUTE, function () { + return response($this->response, 200); + })->defaults('etag_tables', 'processes'); + + // Initial request to get the ETAg. + $response = $this->get(self::TEST_ROUTE); + $etag = $response->headers->get('ETag'); + $this->assertNotNull($etag, 'ETag should be set in the initial response'); + + // Perform a second request sending the `If-None-Match`. + $responseWithMatchingEtag = $this->withHeaders(['If-None-Match' => $etag]) + ->get(self::TEST_ROUTE); + + // Verify response is 304 Not Modified. + $responseWithMatchingEtag->assertStatus(304); + $this->assertEmpty($responseWithMatchingEtag->getContent(), 'Response content is not empty for 304 Not Modified'); + $this->assertEquals($etag, $responseWithMatchingEtag->headers->get('ETag'), 'ETag does not match the client-provided If-None-Match'); + } +} diff --git a/tests/k6/etag/config.js b/tests/k6/etag/config.js new file mode 100644 index 0000000000..843d73bcc4 --- /dev/null +++ b/tests/k6/etag/config.js @@ -0,0 +1,11 @@ +export const config = { + token: 'FAKE_JWT', // Replace with a valid token. + endpoints: { + startProcesses: 'https://processmaker.test/api/1.0/start_processes?page=1&per_page=15&filter=&order_by=category.name%2Cname&order_direction=asc%2Casc&include=events%2Ccategories&without_event_definitions=true', + }, +}; + +export const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.token}`, +}; \ No newline at end of file diff --git a/tests/k6/etag/detect-dynamic-endpoints.js b/tests/k6/etag/detect-dynamic-endpoints.js new file mode 100644 index 0000000000..091a3722a1 --- /dev/null +++ b/tests/k6/etag/detect-dynamic-endpoints.js @@ -0,0 +1,57 @@ +import http from 'k6/http'; +import { config, headers } from './config.js'; + +export let options = { + vus: 1, + iterations: 5, // n iterations per URL. +}; + +const endpoints = [ + config.endpoints.startProcesses, +]; + +// Object to track ETag history for each endpoint. +const etagHistory = {}; + +// Limit to determine when an endpoint is considered dynamic. +const ETAG_HISTORY_LIMIT = 5; + +export default function () { + endpoints.forEach((url) => { + const res1 = http.get(url, { headers }); + const etag = res1.headers['Etag']; + + // If no ETag is present, log a warning and skip further processing. + if (!etag) { + console.log(`No ETag found for ${url}`); + return; + } + + // Log the ETag value for debugging. + console.log(`ETag: ${etag}`); + + // Initialize the ETag history for this endpoint if not already present. + if (!etagHistory[url]) { + etagHistory[url] = []; + } + + // Add the ETag to the history if it is unique. + if (!etagHistory[url].includes(etag)) { + etagHistory[url].push(etag); + } + + // Keep the history limited to the last N ETags. + if (etagHistory[url].length > ETAG_HISTORY_LIMIT) { + etagHistory[url].shift(); // Remove the oldest ETag + } + + // Check if the endpoint is dynamic. + // If the history is full and all ETags are unique, the endpoint is considered dynamic. + if ( + etagHistory[url].length === ETAG_HISTORY_LIMIT && + new Set(etagHistory[url]).size === ETAG_HISTORY_LIMIT + ) { + console.log(`Dynamic endpoint detected: ${url}`); + } + }); +} \ No newline at end of file diff --git a/tests/k6/etag/long-duration-test.js b/tests/k6/etag/long-duration-test.js new file mode 100644 index 0000000000..5ed1651568 --- /dev/null +++ b/tests/k6/etag/long-duration-test.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import { config, headers } from './config.js'; + +// Metrics to track response durations. +let duration200 = new Trend('duration_200'); +let duration304 = new Trend('duration_304'); + +export let options = { + stages: [ + { duration: '5m', target: 50 }, // Hold 50 VUs for 5 minutes. + ], + thresholds: { + http_req_duration: ['p(95)<1000'], // 95% of requests should respond in < 1s. + duration_200: ['avg<800'], // Ensure 200 OK average duration is < 800ms. + duration_304: ['avg<400'], // Ensure 304 Not Modified avg duration is < 400ms. + }, +}; + +export default function () { + const url = config.endpoints.startProcesses; + let res = http.get(url, { headers }); + duration200.add(res.timings.duration); + check(res, { + 'status is 200': (r) => r.status === 200, + 'ETag exists': (r) => !!r.headers.Etag, + }); + + const etag = res.headers.Etag; + if (etag) { + const conditionalHeaders = { ...headers, 'If-None-Match': etag }; + + let conditionalRes = http.get(url, { headers: conditionalHeaders }); + duration304.add(conditionalRes.timings.duration); + + check(conditionalRes, { + 'status is 304': (r) => r.status === 304, + }); + } + + sleep(1); +} \ No newline at end of file diff --git a/tests/k6/etag/performance-test.js b/tests/k6/etag/performance-test.js new file mode 100644 index 0000000000..0805d98ceb --- /dev/null +++ b/tests/k6/etag/performance-test.js @@ -0,0 +1,46 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend } from 'k6/metrics'; +import { config, headers } from './config.js'; + +// Custom metrics to track request durations. +let duration200 = new Trend('duration_200'); +let duration304 = new Trend('duration_304'); + +export let options = { + stages: [ + { duration: '10s', target: 10 }, + { duration: '20s', target: 50 }, + { duration: '10s', target: 0 }, + ], +}; + +export default function () { + const baseUrl = config.endpoints.startProcesses; + group('ETag Performance', () => { + // Add the duration of the 200 response to the custom metric. + let res = http.get(baseUrl, { headers }); + duration200.add(res.timings.duration); + check(res, { + 'status is 200': (r) => r.status === 200, + }); + + // Use ETag with If-None-Match header to validate 304 response. + const etag = res.headers.Etag; + if (etag) { + const conditionalHeaders = { + ...headers, + 'If-None-Match': etag, + }; + + // Add the duration of the 304 response to the custom metric + let conditionalRes = http.get(baseUrl, { headers: conditionalHeaders }); + duration304.add(conditionalRes.timings.duration); + check(conditionalRes, { + 'status is 304': (r) => r.status === 304, + }); + } + + sleep(1); + }); +} \ No newline at end of file