From 223cc112d2321d738e386d302a9d29082e863115 Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Thu, 23 Jul 2026 17:43:50 -0400 Subject: [PATCH 1/3] feat: add 'do_not_update' field to environment variables for import/export logic --- .../Api/EnvironmentVariablesController.php | 2 +- .../Exporters/EnvironmentVariableExporter.php | 16 ++++++ .../ImportExport/Exporters/ExporterBase.php | 36 +++++++++++- ProcessMaker/ImportExport/Manifest.php | 5 ++ ProcessMaker/Models/EnvironmentVariable.php | 7 +++ .../Models/EnvironmentVariableFactory.php | 1 + ..._update_to_environment_variables_table.php | 28 +++++++++ .../CreateEnvironmentVariableModal.vue | 7 ++- .../components/DoNotUpdateSwitch.vue | 32 +++++++++++ .../processes/environment-variables/edit.js | 4 ++ .../environment-variables/edit.blade.php | 2 + .../Feature/Api/EnvironmentVariablesTest.php | 44 ++++++++++++++ .../Exporters/ScriptExporterTest.php | 57 +++++++++++++++++++ 13 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php create mode 100644 resources/js/processes/environment-variables/components/DoNotUpdateSwitch.vue diff --git a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php index 8a127f3a5c..ffd892d65d 100644 --- a/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php +++ b/ProcessMaker/Http/Controllers/Api/EnvironmentVariablesController.php @@ -182,7 +182,7 @@ public function update(EnvironmentVariable $environment_variable, Request $reque validator($data, EnvironmentVariable::rules($environment_variable), EnvironmentVariable::messages())->validate(); $linkedAsset = EnvironmentVariable::validateAssetLinkConsistency($data); - $fields = ['name', 'description', 'asset_type']; + $fields = ['name', 'description', 'asset_type', 'do_not_update']; if ($linkedAsset) { // Guarantee value is always the selected asset's ID on this instance. $data['value'] = (string) $linkedAsset->id; diff --git a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php index 2a54c70c3a..ed1637ad45 100644 --- a/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php +++ b/ProcessMaker/ImportExport/Exporters/EnvironmentVariableExporter.php @@ -2,9 +2,13 @@ namespace ProcessMaker\ImportExport\Exporters; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use ProcessMaker\Enums\ExporterMap; use ProcessMaker\ImportExport\DependentType; +use ProcessMaker\ImportExport\Manifest; +use ProcessMaker\ImportExport\Options; +use ProcessMaker\ImportExport\Psudomodels\Psudomodel; class EnvironmentVariableExporter extends ExporterBase { @@ -16,6 +20,18 @@ class EnvironmentVariableExporter extends ExporterBase public $incrementStringSeparator = '_'; + public function __construct( + Model|Psudomodel|null $model, + Manifest $manifest, + Options $options, + $ignoreExplicitDiscard + ) { + parent::__construct($model, $manifest, $options, $ignoreExplicitDiscard); + + // PHP does not allow closures as property defaults. + $this->discard = fn ($envVar) => (bool) ($envVar->do_not_update ?? false); + } + public function export() : void { $this->addReference(DependentType::ENVIRONMENT_VARIABLE_VALUE, $this->model->value); diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php index f4ac9c4d90..c0e389c76a 100644 --- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php +++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php @@ -47,6 +47,12 @@ abstract class ExporterBase implements ExporterInterface public $hidden = false; + /** + * When true (or a callable returning true), this asset is explicitly discarded. + * Callables receive the model: fn (Model $model): bool + * + * @var bool|callable + */ public $discard = false; public static $forceUpdate = false; @@ -135,7 +141,7 @@ public function uuid() : string public function include() : bool { - if ($this->discard) { // Explicit Discard (not from passed-in options) + if ($this->isExplicitlyDiscarded()) { // Explicit Discard (not from passed-in options) if (!$this->ignoreExplicitDiscard) { return false; } @@ -148,6 +154,32 @@ public function include() : bool return true; } + /** + * Resolve $discard whether it is a boolean or a callable checked against the model. + */ + public function isExplicitlyDiscarded(): bool + { + if (is_callable($this->discard)) { + return (bool) ($this->discard)($this->model); + } + + return (bool) $this->discard; + } + + /** + * Import-time check: should an existing target model be discarded (not overwritten)? + */ + public static function shouldDiscardExistingModel(?Model $model): bool + { + if (!$model || !$model->exists) { + return false; + } + + $exporter = new static($model, new Manifest(), new Options([]), false); + + return $exporter->isExplicitlyDiscarded(); + } + public function addDependent(string $type, Model|Psudomodel $dependentModel, string $exporterClass, $meta = null) { $uuid = $dependentModel->uuid; @@ -304,7 +336,7 @@ public function toArray() 'hidden' => $this->hidden, 'mode' => $this->mode, 'saveAssetsMode' => $this->saveAssetsMode, - 'explicit_discard' => $this->discard, + 'explicit_discard' => $this->isExplicitlyDiscarded(), 'dependents' => array_map(fn ($d) => $d->toArray(), $this->dependents), 'name' => $this->getName($this->model), 'description' => $this->getDescription(), diff --git a/ProcessMaker/ImportExport/Manifest.php b/ProcessMaker/ImportExport/Manifest.php index 494d3197b5..2a489d6c62 100644 --- a/ProcessMaker/ImportExport/Manifest.php +++ b/ProcessMaker/ImportExport/Manifest.php @@ -158,6 +158,11 @@ public static function getModel($uuid, $assetInfo, $mode, $exporterClass, $isRoo $mode = 'discard'; } + // Per-asset explicit discard (e.g. env vars with do_not_update on the target). + if ($model && $exporterClass::shouldDiscardExistingModel($model)) { + $mode = 'discard'; + } + if ($exporterClass::$forceUpdate) { $mode = 'update'; } diff --git a/ProcessMaker/Models/EnvironmentVariable.php b/ProcessMaker/Models/EnvironmentVariable.php index 38f6e9342b..2cee3a1597 100644 --- a/ProcessMaker/Models/EnvironmentVariable.php +++ b/ProcessMaker/Models/EnvironmentVariable.php @@ -17,6 +17,7 @@ * @OA\Property(property="description", type="string"), * @OA\Property(property="value", type="string"), * @OA\Property(property="asset_type", type="string", nullable=true), + * @OA\Property(property="do_not_update", type="boolean"), * ), * @OA\Schema( * schema="EnvironmentVariable", @@ -41,6 +42,11 @@ class EnvironmentVariable extends ProcessMakerModel 'description', 'value', 'asset_type', + 'do_not_update', + ]; + + protected $casts = [ + 'do_not_update' => 'boolean', ]; protected $hidden = [ @@ -102,6 +108,7 @@ public static function rules($existing = null) 'string', Rule::in($allowedAssetTypes), ], + 'do_not_update' => 'boolean', ]; } diff --git a/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php b/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php index 9c63ccdf2f..fbdf253ce9 100644 --- a/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php +++ b/database/factories/ProcessMaker/Models/EnvironmentVariableFactory.php @@ -18,6 +18,7 @@ public function definition() 'name' => $this->faker->word(), 'description' => $this->faker->sentence(), 'value' => $this->faker->sentence(), + 'do_not_update' => false, ]; } } diff --git a/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php new file mode 100644 index 0000000000..122397d593 --- /dev/null +++ b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php @@ -0,0 +1,28 @@ +boolean('do_not_update')->default(false)->after('asset_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('do_not_update'); + }); + } +}; diff --git a/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue b/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue index 77b52b5a73..ca604e149e 100644 --- a/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue +++ b/resources/js/processes/environment-variables/components/CreateEnvironmentVariableModal.vue @@ -41,6 +41,7 @@ :value.sync="value" :errors="errors" /> + @@ -48,9 +49,10 @@ diff --git a/resources/js/processes/environment-variables/edit.js b/resources/js/processes/environment-variables/edit.js index 47d64ae5d4..952d56956f 100644 --- a/resources/js/processes/environment-variables/edit.js +++ b/resources/js/processes/environment-variables/edit.js @@ -1,5 +1,6 @@ import Vue from "vue"; import AssetLinkFields from "./components/AssetLinkFields.vue"; +import DoNotUpdateSwitch from "./components/DoNotUpdateSwitch.vue"; const initial = window.ProcessMaker.EnvironmentVariableEdit || {}; @@ -7,6 +8,7 @@ new Vue({ el: "#editEnvironmentVariable", components: { AssetLinkFields, + DoNotUpdateSwitch, }, data() { return { @@ -16,6 +18,7 @@ new Vue({ description: initial.description, value: initial.value || null, asset_type: initial.asset_type || null, + do_not_update: !!initial.do_not_update, }, errors: { name: null, @@ -44,6 +47,7 @@ new Vue({ description: this.formData.description, asset_type: this.formData.asset_type, value: this.formData.value, + do_not_update: this.formData.do_not_update, }; ProcessMaker.apiClient.put(`environment_variables/${this.formData.id}`, payload) .then(() => { diff --git a/resources/views/processes/environment-variables/edit.blade.php b/resources/views/processes/environment-variables/edit.blade.php index ec70e02e3e..4288228451 100644 --- a/resources/views/processes/environment-variables/edit.blade.php +++ b/resources/views/processes/environment-variables/edit.blade.php @@ -38,6 +38,7 @@ :errors="errors" value-hint="{{ __('For security purposes, this field will always appear empty') }}" > +
{{ html()->button(__('Cancel'), 'button')->class('btn btn-outline-secondary')->attribute('@click', 'onClose') }} @@ -58,6 +59,7 @@ asset_type: @json($environmentVariable->asset_type), // Safe to expose when linked: value is the asset numeric ID, not a secret. value: @json($environmentVariable->asset_type ? $environmentVariable->value : null), + do_not_update: @json((bool) $environmentVariable->do_not_update), }; diff --git a/tests/Feature/Api/EnvironmentVariablesTest.php b/tests/Feature/Api/EnvironmentVariablesTest.php index 2434a85ea3..b00f5c7674 100644 --- a/tests/Feature/Api/EnvironmentVariablesTest.php +++ b/tests/Feature/Api/EnvironmentVariablesTest.php @@ -163,6 +163,50 @@ public function test_it_should_successfully_update_an_environment_variable() $this->assertEquals('newvalue', $variable->value); } + /** @test */ + public function test_it_should_create_an_environment_variable_with_do_not_update() + { + $data = [ + 'name' => 'protectedVar', + 'description' => 'protected description', + 'value' => 'secret', + 'do_not_update' => true, + ]; + + $response = $this->apiCall('POST', self::API_TEST_VARIABLES, $data); + + $response->assertStatus(201); + $response->assertJsonFragment(['do_not_update' => true]); + $this->assertDatabaseHas('environment_variables', [ + 'name' => 'protectedVar', + 'do_not_update' => true, + ]); + } + + /** @test */ + public function test_it_should_update_do_not_update_flag() + { + $variable = EnvironmentVariable::factory()->create([ + 'name' => 'testname', + 'value' => 'testvalue', + 'do_not_update' => false, + ]); + + $data = [ + 'name' => 'testname', + 'description' => 'updated description', + 'do_not_update' => true, + ]; + $response = $this->apiCall('PUT', self::API_TEST_VARIABLES . '/' . $variable->id, $data); + + $response->assertStatus(200); + $response->assertJsonFragment(['do_not_update' => true]); + $this->assertDatabaseHas('environment_variables', [ + 'id' => $variable->id, + 'do_not_update' => true, + ]); + } + /** @test */ public function test_it_should_return_paginated_environment_variables_during_index() { diff --git a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php index a432e59ffc..437eb5e912 100644 --- a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php @@ -155,6 +155,63 @@ public function testRunAsUserIdNull() $this->assertNull($script->run_as_user_id); } + /** + * When the target env var has do_not_update, import must preserve its value. + */ + public function testDoNotUpdateEnvironmentVariablePreservedOnImport() + { + DB::beginTransaction(); + $protected = EnvironmentVariable::factory()->create([ + 'name' => 'PROTECTED_API_TOKEN', + 'description' => 'source description', + 'value' => 'source-secret', + 'do_not_update' => false, + ]); + $unprotected = EnvironmentVariable::factory()->create([ + 'name' => 'PLAIN_CONFIG', + 'description' => 'source plain', + 'value' => 'source-plain', + 'do_not_update' => false, + ]); + $script = Script::factory()->create([ + 'title' => 'script with protected env', + 'code' => 'export($script, ScriptExporter::class); + DB::rollBack(); + + $targetProtected = EnvironmentVariable::factory()->create([ + 'name' => 'PROTECTED_API_TOKEN', + 'description' => 'target description', + 'value' => 'keep-me', + 'do_not_update' => true, + ]); + $targetUnprotected = EnvironmentVariable::factory()->create([ + 'name' => 'PLAIN_CONFIG', + 'description' => 'target plain', + 'value' => 'replace-me', + 'do_not_update' => false, + ]); + + $options = new Options([ + $script->uuid => ['mode' => 'update'], + $protected->uuid => ['mode' => 'update'], + $unprotected->uuid => ['mode' => 'update'], + ]); + $this->import($payload, $options); + + $targetProtected->refresh(); + $targetUnprotected->refresh(); + + $this->assertEquals('keep-me', $targetProtected->value); + $this->assertEquals('target description', $targetProtected->description); + $this->assertTrue($targetProtected->do_not_update); + + $this->assertEquals('source-plain', $targetUnprotected->value); + $this->assertEquals('source plain', $targetUnprotected->description); + } + /** * Test that the environment variables are duplicated when they are used in the script * and the import options are set to create a copy From ae637fcb1650ef6bad9065ffc216c2cba5a2f3d8 Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Fri, 24 Jul 2026 09:08:56 -0400 Subject: [PATCH 2/3] refactor: rename shouldDiscardExistingModel to shouldDiscardExistingModelDuringImport for clarity in import logic --- ProcessMaker/ImportExport/Exporters/ExporterBase.php | 11 +++++++++-- ProcessMaker/ImportExport/Manifest.php | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php index c0e389c76a..352f9a656c 100644 --- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php +++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php @@ -168,8 +168,11 @@ public function isExplicitlyDiscarded(): bool /** * Import-time check: should an existing target model be discarded (not overwritten)? + * + * Only callables are evaluated here. Boolean `$discard = true` (Users/Groups) is an + * export-time flag handled by include(), not an import-time skip. */ - public static function shouldDiscardExistingModel(?Model $model): bool + public static function shouldDiscardExistingModelDuringImport(?Model $model): bool { if (!$model || !$model->exists) { return false; @@ -177,7 +180,11 @@ public static function shouldDiscardExistingModel(?Model $model): bool $exporter = new static($model, new Manifest(), new Options([]), false); - return $exporter->isExplicitlyDiscarded(); + if (!is_callable($exporter->discard)) { + return false; + } + + return (bool) ($exporter->discard)($model); } public function addDependent(string $type, Model|Psudomodel $dependentModel, string $exporterClass, $meta = null) diff --git a/ProcessMaker/ImportExport/Manifest.php b/ProcessMaker/ImportExport/Manifest.php index 2a489d6c62..ac5372b777 100644 --- a/ProcessMaker/ImportExport/Manifest.php +++ b/ProcessMaker/ImportExport/Manifest.php @@ -159,7 +159,7 @@ public static function getModel($uuid, $assetInfo, $mode, $exporterClass, $isRoo } // Per-asset explicit discard (e.g. env vars with do_not_update on the target). - if ($model && $exporterClass::shouldDiscardExistingModel($model)) { + if ($model && $exporterClass::shouldDiscardExistingModelDuringImport($model)) { $mode = 'discard'; } From b06c067ffa06906472977bba5836258863adc36b Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Fri, 24 Jul 2026 13:24:43 -0400 Subject: [PATCH 3/3] refactor: update shouldDiscardExistingModelDuringImport method to accept Psudomodel type and improve type checking --- ProcessMaker/ImportExport/Exporters/ExporterBase.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/ImportExport/Exporters/ExporterBase.php b/ProcessMaker/ImportExport/Exporters/ExporterBase.php index 352f9a656c..e951581bee 100644 --- a/ProcessMaker/ImportExport/Exporters/ExporterBase.php +++ b/ProcessMaker/ImportExport/Exporters/ExporterBase.php @@ -171,10 +171,11 @@ public function isExplicitlyDiscarded(): bool * * Only callables are evaluated here. Boolean `$discard = true` (Users/Groups) is an * export-time flag handled by include(), not an import-time skip. + * Psudomodels (e.g. Signal) are never discarded by this path. */ - public static function shouldDiscardExistingModelDuringImport(?Model $model): bool + public static function shouldDiscardExistingModelDuringImport(Model|Psudomodel|null $model): bool { - if (!$model || !$model->exists) { + if (!$model instanceof Model || !$model->exists) { return false; }