Skip to content
53 changes: 53 additions & 0 deletions ProcessMaker/Models/ProcessRequestToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
<?php

namespace Tests\Unit\ProcessMaker\Models;

use ProcessMaker\Models\Process;
use ProcessMaker\Models\ProcessRequest;
use ProcessMaker\Models\ProcessRequestToken;
use ProcessMaker\Models\User;
use ReflectionClass;
use Tests\TestCase;

class ProcessRequestTokenElementDestinationTest extends TestCase
{
/**
* Invoke a private method on an object.
*/
private function invokePrivateMethod(object $object, string $methodName, array $args = []): mixed
{
$reflection = new ReflectionClass($object);
$method = $reflection->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 = '&#104;&#116;&#116;&#112;&#115;&#58;//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);
}
}
Loading