diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 68de753276..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; @@ -1440,6 +1442,54 @@ 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) { + 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 ?? 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) + $json = json_encode($context, JSON_THROW_ON_ERROR); + $normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + 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 +1531,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'); diff --git a/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php new file mode 100644 index 0000000000..bab69fba2c --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php @@ -0,0 +1,318 @@ +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); + } + + /** + * 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); + } +}