From 5e154f9fbfd20c7a49e7ce7880ba65f2517f98af Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 28 Oct 2026 16:30:12 -0400 Subject: [PATCH 1/5] test: add tests for EnsureAccountAllowsAccess middleware --- .../EnsureAccountAllowsAccessTest.php | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php diff --git a/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php new file mode 100644 index 0000000000..08c8c188b2 --- /dev/null +++ b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php @@ -0,0 +1,139 @@ + 'ACTIVE']); + $webGuard = Mockery::mock(); + $webGuard->shouldReceive('check')->once()->andReturn(true); + $webGuard->shouldReceive('user')->once()->andReturn($user); + + Auth::shouldReceive('guard')->with('web')->andReturn($webGuard); + Auth::shouldReceive('guard')->never()->with('api'); + + $request = Request::create('/', 'GET', [], [], [], [ + 'HTTP_AUTHORIZATION' => 'Bearer undefined', + ]); + + $response = (new EnsureAccountAllowsAccess())->handle($request, fn () => response('allowed')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('allowed', $response->getContent()); + } + + /** + * @dataProvider deniedWebStatuses + */ + public function test_web_middleware_denies_users_with_a_disallowed_status(string $status): void + { + $user = new User(['status' => $status]); + $webGuard = Mockery::mock(); + $webGuard->shouldReceive('check')->once()->andReturn(true); + $webGuard->shouldReceive('user')->once()->andReturn($user); + $webGuard->shouldReceive('logout')->once(); + + Auth::shouldReceive('guard')->with('web')->andReturn($webGuard); + Auth::shouldReceive('guard')->never()->with('api'); + + $response = (new EnsureAccountAllowsAccess())->handle(Request::create('/', 'GET'), fn () => response('allowed')); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertStringEndsWith('/login', $response->headers->get('Location')); + } + + public function deniedWebStatuses(): array + { + return [ + 'blocked' => ['BLOCKED'], + 'inactive' => ['INACTIVE'], + ]; + } + + public function test_api_account_check_does_not_resolve_the_web_guard_for_an_active_user(): void + { + $user = new User(['status' => 'ACTIVE']); + $apiGuard = Mockery::mock(); + $apiGuard->shouldReceive('check')->once()->andReturn(true); + $apiGuard->shouldReceive('user')->once()->andReturn($user); + + Auth::shouldReceive('guard')->never()->with('web'); + Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard); + + $response = EnsureAccountAllowsAccess::blockingResponseForRequest( + Request::create('/api/test', 'GET'), + ['api'] + ); + + $this->assertNull($response); + } + + /** + * @dataProvider deniedApiStatuses + */ + public function test_api_account_check_denies_users_with_a_disallowed_status(string $status, string $message): void + { + $user = new User(['status' => $status]); + $apiGuard = Mockery::mock(); + $apiGuard->shouldReceive('check')->once()->andReturn(true); + $apiGuard->shouldReceive('user')->once()->andReturn($user); + $apiGuard->shouldReceive('logout')->once(); + + Auth::shouldReceive('guard')->never()->with('web'); + Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard); + + $response = EnsureAccountAllowsAccess::blockingResponseForRequest( + Request::create('/api/test', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']), + ['api'] + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame(['message' => $message], json_decode($response->getContent(), true)); + } + + public function deniedApiStatuses(): array + { + return [ + 'blocked' => ['BLOCKED', 'Account locked after too many failed attempts. Contact administrator.'], + 'inactive' => ['INACTIVE', 'Unauthorized'], + ]; + } + + public function test_processmaker_authenticate_checks_only_the_authenticated_api_guard(): void + { + $user = new User(['status' => 'INACTIVE']); + $apiGuard = Mockery::mock(); + $apiGuard->shouldReceive('check')->once()->andReturn(true); + $apiGuard->shouldReceive('user')->once()->andReturn($user); + $apiGuard->shouldReceive('logout')->once(); + + Auth::shouldReceive('guard')->never()->with('web'); + Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard); + + $middleware = new class extends ProcessMakerAuthenticate { + protected function authenticate($request, array $guards) + { + // Authentication is outside the scope of this guard-selection test. + } + }; + + $response = $middleware->handle( + Request::create('/api/test', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']), + fn () => response('allowed'), + 'api' + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame(['message' => 'Unauthorized'], json_decode($response->getContent(), true)); + } +} From f412a1ce3ab436de6b593f7786b13b0ead34012f Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 28 Oct 2026 16:51:49 -0400 Subject: [PATCH 2/5] fix: enhance EnsureAccountAllowsAccess middleware to accept dynamic guards for blocking response --- .../Http/Middleware/EnsureAccountAllowsAccess.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php b/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php index 5cedec09a4..2ae376060a 100644 --- a/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php +++ b/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php @@ -16,12 +16,15 @@ class EnsureAccountAllowsAccess private const DENIED_STATUSES = ['BLOCKED', 'INACTIVE']; /** - * If any active guard has a blocked/inactive user, log out and return the appropriate response. - * Used by the web middleware group and by ProcessMakerAuthenticate (covers all auth:api routes, including packages). + * If an authenticated user for one of the supplied guards is blocked/inactive, log out and return the appropriate response. + * + * @param array $guards */ - public static function blockingResponseForRequest(Request $request): ?Response + public static function blockingResponseForRequest(Request $request, array $guards = []): ?Response { - foreach (['web', 'api'] as $guard) { + foreach ($guards ?: [config('auth.defaults.guard')] as $guard) { + $guard = $guard ?: config('auth.defaults.guard'); + if (!Auth::guard($guard)->check()) { continue; } @@ -40,7 +43,7 @@ public static function blockingResponseForRequest(Request $request): ?Response public function handle(Request $request, Closure $next): Response { - $blocked = self::blockingResponseForRequest($request); + $blocked = self::blockingResponseForRequest($request, ['web']); if ($blocked !== null) { return $blocked; } From b28410331417510052e45e388e068e0f7a0b6542 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 28 Oct 2026 16:55:41 -0400 Subject: [PATCH 3/5] feat: update ProcessMakerAuthenticate middleware to pass guards to EnsureAccountAllowsAccess and adjust tests for static data providers --- .../Http/Middleware/ProcessMakerAuthenticate.php | 2 +- .../Middleware/EnsureAccountAllowsAccessTest.php | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index b64c13812e..5d95579871 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -18,7 +18,7 @@ public function handle($request, Closure $next, ...$guards) { $this->authenticate($request, $guards); - if ($blocked = EnsureAccountAllowsAccess::blockingResponseForRequest($request)) { + if ($blocked = EnsureAccountAllowsAccess::blockingResponseForRequest($request, $guards)) { return $blocked; } diff --git a/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php index 08c8c188b2..5e467394a2 100644 --- a/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php +++ b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use ProcessMaker\Http\Middleware\EnsureAccountAllowsAccess; use ProcessMaker\Http\Middleware\ProcessMakerAuthenticate; use ProcessMaker\Models\User; @@ -32,9 +33,7 @@ public function test_web_middleware_does_not_resolve_the_api_guard_for_a_malform $this->assertSame('allowed', $response->getContent()); } - /** - * @dataProvider deniedWebStatuses - */ + #[DataProvider('deniedWebStatuses')] public function test_web_middleware_denies_users_with_a_disallowed_status(string $status): void { $user = new User(['status' => $status]); @@ -52,7 +51,7 @@ public function test_web_middleware_denies_users_with_a_disallowed_status(string $this->assertStringEndsWith('/login', $response->headers->get('Location')); } - public function deniedWebStatuses(): array + public static function deniedWebStatuses(): array { return [ 'blocked' => ['BLOCKED'], @@ -78,9 +77,7 @@ public function test_api_account_check_does_not_resolve_the_web_guard_for_an_act $this->assertNull($response); } - /** - * @dataProvider deniedApiStatuses - */ + #[DataProvider('deniedApiStatuses')] public function test_api_account_check_denies_users_with_a_disallowed_status(string $status, string $message): void { $user = new User(['status' => $status]); @@ -101,7 +98,7 @@ public function test_api_account_check_denies_users_with_a_disallowed_status(str $this->assertSame(['message' => $message], json_decode($response->getContent(), true)); } - public function deniedApiStatuses(): array + public static function deniedApiStatuses(): array { return [ 'blocked' => ['BLOCKED', 'Account locked after too many failed attempts. Contact administrator.'], @@ -120,7 +117,7 @@ public function test_processmaker_authenticate_checks_only_the_authenticated_api Auth::shouldReceive('guard')->never()->with('web'); Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard); - $middleware = new class extends ProcessMakerAuthenticate { + $middleware = new class(app('auth')) extends ProcessMakerAuthenticate { protected function authenticate($request, array $guards) { // Authentication is outside the scope of this guard-selection test. From 29ba1dabb2f8fdeef2502868c55c946d8dced2f4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 28 Oct 2026 16:57:36 -0400 Subject: [PATCH 4/5] test: enhance k6 ETag tests with configuration validation and dynamic headers --- tests/k6/etag/README.md | 20 ++++++++++++++++ tests/k6/etag/config.js | 29 ++++++++++++++++++----- tests/k6/etag/detect-dynamic-endpoints.js | 10 ++++++-- tests/k6/etag/long-duration-test.js | 9 +++++-- tests/k6/etag/performance-test.js | 9 +++++-- 5 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 tests/k6/etag/README.md diff --git a/tests/k6/etag/README.md b/tests/k6/etag/README.md new file mode 100644 index 0000000000..8c42b32623 --- /dev/null +++ b/tests/k6/etag/README.md @@ -0,0 +1,20 @@ +# ETag k6 tests + +The ETag tests require a valid ProcessMaker OAuth bearer token. They do not run with a missing, `null`, `undefined`, or `FAKE_JWT` token. + +Set the target URL and token through environment variables: + +```bash +PM_K6_BASE_URL=https://performance2-qa.processmaker.net \ +PM_K6_TOKEN='' \ +k6 run tests/k6/etag/performance-test.js +``` + +Use the same variables for the other tests: + +```bash +k6 run tests/k6/etag/long-duration-test.js +k6 run tests/k6/etag/detect-dynamic-endpoints.js +``` + +Do not set an `Authorization` header outside these scripts when the token is unavailable. Test setup must fail instead of sending `Bearer null` or `Bearer undefined`. diff --git a/tests/k6/etag/config.js b/tests/k6/etag/config.js index 843d73bcc4..09455af96f 100644 --- a/tests/k6/etag/config.js +++ b/tests/k6/etag/config.js @@ -1,11 +1,28 @@ +const baseUrl = (__ENV.PM_K6_BASE_URL || '').replace(/\/$/, ''); + export const config = { - token: 'FAKE_JWT', // Replace with a valid token. + token: __ENV.PM_K6_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', + startProcesses: `${baseUrl}/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 +export function validateConfiguration() { + if (!baseUrl) { + throw new Error('PM_K6_BASE_URL is required.'); + } + + const token = config.token.trim(); + if (!token || ['null', 'undefined', 'fake_jwt'].includes(token.toLowerCase())) { + throw new Error('PM_K6_TOKEN must contain a valid bearer token.'); + } +} + +export function getHeaders() { + validateConfiguration(); + + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.token}`, + }; +} diff --git a/tests/k6/etag/detect-dynamic-endpoints.js b/tests/k6/etag/detect-dynamic-endpoints.js index 091a3722a1..d259fa0f6f 100644 --- a/tests/k6/etag/detect-dynamic-endpoints.js +++ b/tests/k6/etag/detect-dynamic-endpoints.js @@ -1,5 +1,5 @@ import http from 'k6/http'; -import { config, headers } from './config.js'; +import { config, getHeaders, validateConfiguration } from './config.js'; export let options = { vus: 1, @@ -16,7 +16,13 @@ const etagHistory = {}; // Limit to determine when an endpoint is considered dynamic. const ETAG_HISTORY_LIMIT = 5; +export function setup() { + validateConfiguration(); +} + export default function () { + const headers = getHeaders(); + endpoints.forEach((url) => { const res1 = http.get(url, { headers }); const etag = res1.headers['Etag']; @@ -54,4 +60,4 @@ export default function () { 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 index 5ed1651568..802e04ff47 100644 --- a/tests/k6/etag/long-duration-test.js +++ b/tests/k6/etag/long-duration-test.js @@ -1,7 +1,7 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Trend } from 'k6/metrics'; -import { config, headers } from './config.js'; +import { config, getHeaders, validateConfiguration } from './config.js'; // Metrics to track response durations. let duration200 = new Trend('duration_200'); @@ -18,8 +18,13 @@ export let options = { }, }; +export function setup() { + validateConfiguration(); +} + export default function () { const url = config.endpoints.startProcesses; + const headers = getHeaders(); let res = http.get(url, { headers }); duration200.add(res.timings.duration); check(res, { @@ -40,4 +45,4 @@ export default function () { } sleep(1); -} \ No newline at end of file +} diff --git a/tests/k6/etag/performance-test.js b/tests/k6/etag/performance-test.js index 0805d98ceb..47fd63efdf 100644 --- a/tests/k6/etag/performance-test.js +++ b/tests/k6/etag/performance-test.js @@ -1,7 +1,7 @@ import http from 'k6/http'; import { check, sleep, group } from 'k6'; import { Trend } from 'k6/metrics'; -import { config, headers } from './config.js'; +import { config, getHeaders, validateConfiguration } from './config.js'; // Custom metrics to track request durations. let duration200 = new Trend('duration_200'); @@ -15,8 +15,13 @@ export let options = { ], }; +export function setup() { + validateConfiguration(); +} + export default function () { const baseUrl = config.endpoints.startProcesses; + const headers = getHeaders(); group('ETag Performance', () => { // Add the duration of the 200 response to the custom metric. let res = http.get(baseUrl, { headers }); @@ -43,4 +48,4 @@ export default function () { sleep(1); }); -} \ No newline at end of file +} From b9a7090bd3323d003e360ebbad356b65ec111e92 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 29 Oct 2026 10:27:59 -0400 Subject: [PATCH 5/5] fix: avoid logout on stateless API guard --- .../Http/Middleware/EnsureAccountAllowsAccess.php | 13 +++++++++---- .../Middleware/EnsureAccountAllowsAccessTest.php | 14 +++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php b/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php index 2ae376060a..5060cba876 100644 --- a/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php +++ b/ProcessMaker/Http/Middleware/EnsureAccountAllowsAccess.php @@ -3,6 +3,7 @@ namespace ProcessMaker\Http\Middleware; use Closure; +use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use ProcessMaker\Models\User; @@ -53,11 +54,15 @@ public function handle(Request $request, Closure $next): Response public static function denyAccess(Request $request, string $guard, User $user): Response { - Auth::guard($guard)->logout(); + $authGuard = Auth::guard($guard); - if ($request->hasSession()) { - $request->session()->invalidate(); - $request->session()->regenerateToken(); + if ($authGuard instanceof StatefulGuard) { + $authGuard->logout(); + + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } } if ($guard === 'api' || $request->expectsJson()) { diff --git a/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php index 5e467394a2..e359edefef 100644 --- a/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php +++ b/tests/Feature/Http/Middleware/EnsureAccountAllowsAccessTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Http\Middleware; +use Illuminate\Contracts\Auth\Guard; +use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Mockery; @@ -16,7 +18,7 @@ class EnsureAccountAllowsAccessTest extends TestCase public function test_web_middleware_does_not_resolve_the_api_guard_for_a_malformed_bearer_token(): void { $user = new User(['status' => 'ACTIVE']); - $webGuard = Mockery::mock(); + $webGuard = Mockery::mock(StatefulGuard::class); $webGuard->shouldReceive('check')->once()->andReturn(true); $webGuard->shouldReceive('user')->once()->andReturn($user); @@ -37,7 +39,7 @@ public function test_web_middleware_does_not_resolve_the_api_guard_for_a_malform public function test_web_middleware_denies_users_with_a_disallowed_status(string $status): void { $user = new User(['status' => $status]); - $webGuard = Mockery::mock(); + $webGuard = Mockery::mock(StatefulGuard::class); $webGuard->shouldReceive('check')->once()->andReturn(true); $webGuard->shouldReceive('user')->once()->andReturn($user); $webGuard->shouldReceive('logout')->once(); @@ -62,7 +64,7 @@ public static function deniedWebStatuses(): array public function test_api_account_check_does_not_resolve_the_web_guard_for_an_active_user(): void { $user = new User(['status' => 'ACTIVE']); - $apiGuard = Mockery::mock(); + $apiGuard = Mockery::mock(Guard::class); $apiGuard->shouldReceive('check')->once()->andReturn(true); $apiGuard->shouldReceive('user')->once()->andReturn($user); @@ -81,10 +83,9 @@ public function test_api_account_check_does_not_resolve_the_web_guard_for_an_act public function test_api_account_check_denies_users_with_a_disallowed_status(string $status, string $message): void { $user = new User(['status' => $status]); - $apiGuard = Mockery::mock(); + $apiGuard = Mockery::mock(Guard::class); $apiGuard->shouldReceive('check')->once()->andReturn(true); $apiGuard->shouldReceive('user')->once()->andReturn($user); - $apiGuard->shouldReceive('logout')->once(); Auth::shouldReceive('guard')->never()->with('web'); Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard); @@ -109,10 +110,9 @@ public static function deniedApiStatuses(): array public function test_processmaker_authenticate_checks_only_the_authenticated_api_guard(): void { $user = new User(['status' => 'INACTIVE']); - $apiGuard = Mockery::mock(); + $apiGuard = Mockery::mock(Guard::class); $apiGuard->shouldReceive('check')->once()->andReturn(true); $apiGuard->shouldReceive('user')->once()->andReturn($user); - $apiGuard->shouldReceive('logout')->once(); Auth::shouldReceive('guard')->never()->with('web'); Auth::shouldReceive('guard')->with('api')->andReturn($apiGuard);