From 0f3d235d26160f08fbb22e2863213e6feeaf8e21 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 10 Feb 2026 19:12:03 -0400 Subject: [PATCH 1/7] =?UTF-8?q?FOUR-29250=20End=20Event=20=E2=80=93=20Exte?= =?UTF-8?q?rnal=20URL=20with=20Mustache/FEEL=20Support=20Description:=20fe?= =?UTF-8?q?at(end=20event):=20support=20Mustache=20in=20external=20URL=20f?= =?UTF-8?q?or=20element=20destination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve Mustache expressions in end event "External URL" when the token reaches the end event, using the same context as scripts/screens (APP_URL, _request, _user, process variables). - Add getElementDestinationMustacheContext() to build context via DataManager with fallback; normalize to plain array for Mustache. - Add resolveElementDestinationUrl() to decode HTML entities and render URL template with MustacheExpressionEvaluator. FEEL is not supported. - Apply resolution only for externalURL type; conditional redirect URLs also go through Mustache when destination is external URL. - Harden getElementDestinationAttribute(): ensure conditionalRedirectProp and elementDestinationProp are never null (json_decode ?? [], pass ?? []). - Modeler: allow URL validation when string contains {{ (Mustache); update helper and error copy to document _request.id, _user.id, process vars. Related tickets: https://processmaker.atlassian.net/browse/FOUR-29250 --- ProcessMaker/Models/ProcessRequestToken.php | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 68de753276..e897285003 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -24,6 +24,8 @@ use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; use ProcessMaker\Nayra\Storage\BpmnDocument; +use ProcessMaker\Managers\DataManager; +use ProcessMaker\Models\MustacheExpressionEvaluator; use ProcessMaker\Notifications\ActivityActivatedNotification; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\Expression; @@ -1440,6 +1442,49 @@ public function reassign($toUserId, User $requestingUser, $comments = '') } } + /** + * Build context for Mustache (end event external URL). Same as scripts/screens: _user, _request, process data, APP_URL. + */ + private function getElementDestinationMustacheContext(): array + { + try { + $context = (new DataManager())->getData($this); + } catch (Throwable $e) { + $request = $this->processRequest; + $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); + $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); + if ($user) { + $userData = $user->attributesToArray(); + unset($userData['remember_token']); + $context['_user'] = $userData; + } + } + + $context['APP_URL'] = config('app.url'); + + // Normalize to plain arrays/scalars so Mustache resolves all keys (common PHP idiom) + $normalized = json_decode(json_encode($context), true); + + return is_array($normalized) ? $normalized : []; + } + + /** + * Resolve Mustache in end event external URL. FEEL is not supported here; use Mustache only. + * Context: APP_URL, _request, _user, process variables (same as getElementDestinationMustacheContext). + * + * Example (Mustache): + * {{APP_URL}}/admin/users/{{_request.id}}/edit -> https://example.com/admin/users/123/edit + * {{APP_URL}}/webentry/{{_request.id}} -> https://example.com/webentry/123 + * {{APP_URL}}/path/{{my_process_var}} -> uses process variable my_process_var + */ + private function resolveElementDestinationUrl(string $url): string + { + $url = html_entity_decode($url, ENT_QUOTES | ENT_HTML401, 'UTF-8'); + $context = $this->getElementDestinationMustacheContext(); + + return (new MustacheExpressionEvaluator())->render($url, $context); + } + /** * Determines the destination based on the type of element destination property * @@ -1481,6 +1526,9 @@ private function getElementDestination($elementDestinationType, $elementDestinat $elementDestination = $elementDestinationProp['value']['url'] ?? null; } } + if ($elementDestinationType === 'externalURL' && is_string($elementDestination) && $elementDestination !== '') { + $elementDestination = $this->resolveElementDestinationUrl($elementDestination); + } break; case 'taskList': $elementDestination = route('tasks.index'); From 5ad73daf501b2e43ee8318c67394b0b488ce7653 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 11 Feb 2026 09:13:39 -0400 Subject: [PATCH 2/7] FOUR-29250 Add unit tests for end event external URL Mustache context and resolution. --- ...cessRequestTokenElementDestinationTest.php | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php diff --git a/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php new file mode 100644 index 0000000000..49c01fd873 --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php @@ -0,0 +1,146 @@ +getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $args); + } + + /** + * Test getElementDestinationMustacheContext returns context with APP_URL, _request, _user and process data. + */ + public function testGetElementDestinationMustacheContextReturnsExpectedKeys(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => ['processVar' => 'value123'], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $context = $this->invokePrivateMethod($token, 'getElementDestinationMustacheContext'); + + $this->assertIsArray($context); + $this->assertArrayHasKey('APP_URL', $context); + $this->assertSame(config('app.url'), $context['APP_URL']); + $this->assertArrayHasKey('_request', $context); + $this->assertIsArray($context['_request']); + $this->assertArrayHasKey('id', $context['_request']); + $this->assertSame((string) $request->id, (string) $context['_request']['id']); + $this->assertArrayHasKey('case_number', $context['_request']); + $this->assertArrayHasKey('_user', $context); + $this->assertIsArray($context['_user']); + $this->assertArrayHasKey('id', $context['_user']); + $this->assertArrayHasKey('processVar', $context); + $this->assertSame('value123', $context['processVar']); + } + + /** + * Test resolveElementDestinationUrl resolves Mustache placeholders APP_URL and _request.id. + */ + public function testResolveElementDestinationUrlResolvesMustache(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlTemplate = '{{APP_URL}}/path/{{_request.id}}'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlTemplate]); + + $expectedUrl = config('app.url') . '/path/' . $request->id; + $this->assertSame($expectedUrl, $resolved); + } + + /** + * Test resolveElementDestinationUrl resolves process variable in URL. + */ + public function testResolveElementDestinationUrlResolvesProcessVariable(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => ['segment' => 'admin'], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlTemplate = '{{APP_URL}}/{{segment}}/users'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlTemplate]); + + $this->assertSame(config('app.url') . '/admin/users', $resolved); + } + + /** + * Test resolveElementDestinationUrl decodes HTML entities in template. + */ + public function testResolveElementDestinationUrlDecodesHtmlEntities(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlWithEntities = 'https://example.com/{{_request.id}}'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlWithEntities]); + + $this->assertStringContainsString((string) $request->id, $resolved); + $this->assertStringContainsString('https://example.com/', $resolved); + } +} From 517987f742947ceddd0c0a87379f73e90d41a31a Mon Sep 17 00:00:00 2001 From: Roly Rudy Gutierrez Pinto Date: Wed, 11 Feb 2026 15:56:45 -0400 Subject: [PATCH 3/7] Apply suggestion from @devmiguelangel Co-authored-by: Miguel Angel --- ProcessMaker/Models/ProcessRequestToken.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index e897285003..277efcf14b 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1450,6 +1450,10 @@ private function getElementDestinationMustacheContext(): array try { $context = (new DataManager())->getData($this); } catch (Throwable $e) { + Log::warning('Failed to load Mustache context via DataManager, falling back to request data', [ + 'token_id' => $this->id, + 'error' => $e->getMessage(), + ]); $request = $this->processRequest; $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); From 6af7877849b798605677d4115806cc362d93db9b Mon Sep 17 00:00:00 2001 From: Roly Rudy Gutierrez Pinto Date: Wed, 11 Feb 2026 15:59:45 -0400 Subject: [PATCH 4/7] Apply suggestion from @devmiguelangel Co-authored-by: Miguel Angel --- ProcessMaker/Models/ProcessRequestToken.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 277efcf14b..5673d2fbde 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1456,7 +1456,8 @@ private function getElementDestinationMustacheContext(): array ]); $request = $this->processRequest; $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); - $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); + $user = $this->user ?? auth()->user(); + if ($user) { $userData = $user->attributesToArray(); unset($userData['remember_token']); From f8aac11db38c4a9ebeaf8c8ed4b284ced0537828 Mon Sep 17 00:00:00 2001 From: Roly Rudy Gutierrez Pinto Date: Wed, 11 Feb 2026 16:07:45 -0400 Subject: [PATCH 5/7] Apply suggestion from @devmiguelangel Co-authored-by: Miguel Angel --- ProcessMaker/Models/ProcessRequestToken.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 5673d2fbde..717d4e4488 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1468,7 +1468,8 @@ private function getElementDestinationMustacheContext(): array $context['APP_URL'] = config('app.url'); // Normalize to plain arrays/scalars so Mustache resolves all keys (common PHP idiom) - $normalized = json_decode(json_encode($context), true); + $json = json_encode($context, JSON_THROW_ON_ERROR); + $normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR); return is_array($normalized) ? $normalized : []; } From 96a4eb24ef4b3b00929ef6147d474ac1b6ba61c5 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 11 Feb 2026 16:53:46 -0400 Subject: [PATCH 6/7] FOUR-29250 ProcessRequestToken: improve getElementDestinationMustacheContext - Log warning when falling back from DataManager to request data - Use auth()->user() instead of Auth facade - Use JSON_THROW_ON_ERROR for context normalization --- ProcessMaker/Models/ProcessRequestToken.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 717d4e4488..e056d2a1c9 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -17,6 +17,8 @@ use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ActivityReassignment; use ProcessMaker\Facades\WorkflowUserManager; +use ProcessMaker\Managers\DataManager; +use ProcessMaker\Models\MustacheExpressionEvaluator; use ProcessMaker\Nayra\Bpmn\TokenTrait; use ProcessMaker\Nayra\Contracts\Bpmn\ActivityInterface; use ProcessMaker\Nayra\Contracts\Bpmn\FlowElementInterface; @@ -24,8 +26,6 @@ use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; use ProcessMaker\Nayra\Storage\BpmnDocument; -use ProcessMaker\Managers\DataManager; -use ProcessMaker\Models\MustacheExpressionEvaluator; use ProcessMaker\Notifications\ActivityActivatedNotification; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\Expression; @@ -1457,7 +1457,6 @@ private function getElementDestinationMustacheContext(): array $request = $this->processRequest; $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); $user = $this->user ?? auth()->user(); - if ($user) { $userData = $user->attributesToArray(); unset($userData['remember_token']); From cafd3393665ff52a07402956004a1b5d03534539 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Thu, 12 Feb 2026 22:32:12 -0400 Subject: [PATCH 7/7] FOUR-29250 Add unit tests for ProcessRequestToken element destination Mustache helpers Cover getElementDestinationMustacheContext (normalized context, no remember_token, token without user) and resolveElementDestinationUrl (_user placeholders, empty string, no placeholders). --- ...cessRequestTokenElementDestinationTest.php | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php index 49c01fd873..bab69fba2c 100644 --- a/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php +++ b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php @@ -143,4 +143,176 @@ public function testResolveElementDestinationUrlDecodesHtmlEntities(): void $this->assertStringContainsString((string) $request->id, $resolved); $this->assertStringContainsString('https://example.com/', $resolved); } + + /** + * Test getElementDestinationMustacheContext excludes remember_token from _user. + */ + public function testGetElementDestinationMustacheContextExcludesRememberTokenFromUser(): void + { + $user = User::factory()->create([ + 'status' => 'ACTIVE', + 'remember_token' => 'secret-token', + ]); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $context = $this->invokePrivateMethod($token, 'getElementDestinationMustacheContext'); + + $this->assertArrayHasKey('_user', $context); + $this->assertIsArray($context['_user']); + $this->assertArrayNotHasKey('remember_token', $context['_user']); + } + + /** + * Test getElementDestinationMustacheContext returns normalized context (arrays and scalars only). + */ + public function testGetElementDestinationMustacheContextReturnsNormalizedArray(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => ['nested' => ['a' => 1, 'b' => 'two']], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $context = $this->invokePrivateMethod($token, 'getElementDestinationMustacheContext'); + + $this->assertIsArray($context); + $this->assertArrayHasKey('APP_URL', $context); + $this->assertIsString($context['APP_URL']); + $this->assertArrayHasKey('nested', $context); + $this->assertIsArray($context['nested']); + $this->assertSame(1, $context['nested']['a']); + $this->assertSame('two', $context['nested']['b']); + } + + /** + * Test getElementDestinationMustacheContext includes APP_URL when token has no user. + */ + public function testGetElementDestinationMustacheContextWhenTokenHasNoUser(): void + { + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => null, + 'data' => ['foo' => 'bar'], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => null, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $context = $this->invokePrivateMethod($token, 'getElementDestinationMustacheContext'); + + $this->assertIsArray($context); + $this->assertSame(config('app.url'), $context['APP_URL']); + $this->assertArrayHasKey('_request', $context); + $this->assertSame('bar', $context['foo']); + } + + /** + * Test resolveElementDestinationUrl resolves _user placeholder. + */ + public function testResolveElementDestinationUrlResolvesUserPlaceholder(): void + { + $user = User::factory()->create(['status' => 'ACTIVE', 'username' => 'johndoe']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlTemplate = '{{APP_URL}}/users/{{_user.id}}/{{_user.username}}'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlTemplate]); + + $expectedUrl = config('app.url') . '/users/' . $user->id . '/johndoe'; + $this->assertSame($expectedUrl, $resolved); + } + + /** + * Test resolveElementDestinationUrl with empty string returns empty string. + */ + public function testResolveElementDestinationUrlWithEmptyString(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', ['']); + + $this->assertSame('', $resolved); + } + + /** + * Test resolveElementDestinationUrl with no placeholders returns URL unchanged (after entity decode). + */ + public function testResolveElementDestinationUrlWithNoPlaceholders(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $plainUrl = 'https://example.com/static/path'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$plainUrl]); + + $this->assertSame($plainUrl, $resolved); + } }