@@ -218,6 +247,29 @@ const confirmPublishNewVersionText = computed(() => vue.$t(
{ bundleName: bundle.value?.name },
));
+const invalidAssets = computed(
+ () => bundle.value?.assets?.filter((asset) => asset.integrity_status !== "valid") || [],
+);
+
+const removeInvalidAsset = async (asset) => {
+ const confirm = await vue.$bvModal.msgBoxConfirm(
+ vue.$t("Remove this unavailable association from the bundle? The underlying asset will not be deleted."),
+ {
+ okTitle: vue.$t("Remove from bundle"),
+ okVariant: "danger",
+ cancelTitle: vue.$t("Cancel"),
+ },
+ );
+ if (!confirm) {
+ return;
+ }
+
+ await window.ProcessMaker.apiClient.delete(
+ `/api/1.0/devlink/local-bundles/assets/${asset.id}`,
+ );
+ await loadAssets();
+};
+
const publishBundle = () => {
selected.value = bundle.value;
confirmPublishNewVersion.value.show();
@@ -277,6 +329,15 @@ width: 100%;
.btn-publish {
width: 104px;
}
+.integrity-alert {
+ margin: 16px 24px;
+}
+.integrity-asset {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+}
.icon-button {
background-color: #E9ECF1;
width: 40px;
diff --git a/tests/Feature/Api/DevLinkTest.php b/tests/Feature/Api/DevLinkTest.php
index f0a0af7b0e..2d9c244d92 100644
--- a/tests/Feature/Api/DevLinkTest.php
+++ b/tests/Feature/Api/DevLinkTest.php
@@ -8,7 +8,9 @@
use PHPUnit\Framework\Attributes\DataProvider;
use ProcessMaker\Http\Controllers\Api\DevLinkController;
use ProcessMaker\Models\Bundle;
+use ProcessMaker\Models\BundleAsset;
use ProcessMaker\Models\DevLink;
+use ProcessMaker\Models\Process;
use ProcessMaker\Models\Screen;
use ProcessMaker\Package\PackageDynamicUI\Models\Dashboard;
use ProcessMaker\Package\PackageDynamicUI\Models\Menu;
@@ -257,6 +259,82 @@ public function testShowBundle()
$this->assertEquals($bundle->id, $response->json()['id']);
}
+ public function testShowBundleReportsUnavailableAssetsWithoutFailing()
+ {
+ $bundle = Bundle::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundle', ['bundle' => $bundle->id]));
+
+ $response->assertOk()
+ ->assertJsonPath('assets.0.id', $bundleAsset->id)
+ ->assertJsonPath('assets.0.name', "Missing Process #$missingProcessId")
+ ->assertJsonPath('assets.0.url', null)
+ ->assertJsonPath('assets.0.integrity_status', BundleAsset::INTEGRITY_MISSING);
+ }
+
+ public function testExportBundleRejectsUnavailableAssets()
+ {
+ $bundle = Bundle::factory()->create(['name' => 'Corrupt Bundle']);
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.export-local-bundle', [
+ 'bundle' => $bundle->id,
+ ]));
+
+ $response->assertStatus(422)
+ ->assertJsonPath('error.code', 422)
+ ->assertJsonPath(
+ 'error.message',
+ 'The bundle Corrupt Bundle contains unavailable assets and cannot be exported.'
+ )
+ ->assertJsonPath('errors.assets.0.bundle_asset_id', $bundleAsset->id)
+ ->assertJsonPath('errors.assets.0.asset_type', Process::class)
+ ->assertJsonPath('errors.assets.0.asset_id', $missingProcessId)
+ ->assertJsonPath('errors.assets.0.integrity_status', BundleAsset::INTEGRITY_MISSING);
+ }
+
+ public function testInstalledBundleAllowsRemovingOnlyUnavailableAssets()
+ {
+ $devLink = DevLink::factory()->create();
+ $bundle = Bundle::factory()->create([
+ 'dev_link_id' => $devLink->id,
+ 'remote_id' => 123,
+ ]);
+ $screen = Screen::factory()->create();
+ $validBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Screen::class,
+ 'asset_id' => $screen->id,
+ ]);
+ $missingProcessId = Process::max('id') + 1000;
+ $invalidBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $this->apiCall('DELETE', route('api.devlink.delete-bundle-asset', [
+ 'bundle_asset' => $validBundleAsset->id,
+ ]))->assertStatus(422);
+ $this->assertDatabaseHas('bundle_assets', ['id' => $validBundleAsset->id]);
+
+ $this->apiCall('DELETE', route('api.devlink.delete-bundle-asset', [
+ 'bundle_asset' => $invalidBundleAsset->id,
+ ]))->assertOk();
+ $this->assertDatabaseMissing('bundle_assets', ['id' => $invalidBundleAsset->id]);
+ }
+
public function testGetBundleAllSettingsReturnsDashboardAndMenuOptions()
{
if (!hasPackage('package-dynamic-ui')) {
diff --git a/tests/Model/BundleAssetTest.php b/tests/Model/BundleAssetTest.php
index 88aa6b0184..42179b1042 100644
--- a/tests/Model/BundleAssetTest.php
+++ b/tests/Model/BundleAssetTest.php
@@ -6,6 +6,7 @@
use ProcessMaker\Models\Bundle;
use ProcessMaker\Models\BundleAsset;
use ProcessMaker\Models\Group;
+use ProcessMaker\Models\Process;
use ProcessMaker\Models\Screen;
use Tests\TestCase;
@@ -16,6 +17,7 @@ public function testCanExport()
$screen = Screen::factory()->create();
$this->assertTrue(BundleAsset::canExport($screen));
+ $this->assertFalse(BundleAsset::canExport(null));
}
public function testExporterNotSupported()
@@ -26,4 +28,55 @@ public function testExporterNotSupported()
$this->expectException(ExporterNotSupported::class);
$bundle->addAsset($group);
}
+
+ public function testMissingAssetCanBeSerialized()
+ {
+ $bundle = Bundle::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $serialized = $bundleAsset->toArray();
+
+ $this->assertSame(BundleAsset::INTEGRITY_MISSING, $serialized['integrity_status']);
+ $this->assertSame("Missing Process #$missingProcessId", $serialized['name']);
+ $this->assertNull($serialized['url']);
+ $this->assertNull($serialized['owner_name']);
+ $this->assertSame([], $serialized['categories']);
+ }
+
+ public function testUnavailableAssetTypeCanBeSerialized()
+ {
+ $bundleAsset = BundleAsset::factory()->create([
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ ]);
+
+ $serialized = $bundleAsset->toArray();
+
+ $this->assertSame(BundleAsset::INTEGRITY_TYPE_UNAVAILABLE, $serialized['integrity_status']);
+ $this->assertSame('Missing Asset #1234', $serialized['name']);
+ $this->assertNull($serialized['url']);
+ }
+
+ public function testDeletingAnAssetLeavesItsBundleAssociationForManualRepair()
+ {
+ $process = Process::factory()->create();
+ $bundleAsset = BundleAsset::factory()->create([
+ 'asset_type' => Process::class,
+ 'asset_id' => $process->id,
+ ]);
+
+ $process->delete();
+
+ $this->assertTrue($process->trashed());
+ $this->assertDatabaseHas('bundle_assets', ['id' => $bundleAsset->id]);
+ $this->assertSame(
+ BundleAsset::INTEGRITY_MISSING,
+ $bundleAsset->refresh()->integrity_status
+ );
+ }
}
diff --git a/tests/Model/BundleTest.php b/tests/Model/BundleTest.php
index d0bee366ee..aa85265b30 100644
--- a/tests/Model/BundleTest.php
+++ b/tests/Model/BundleTest.php
@@ -3,6 +3,7 @@
namespace Tests\Model;
use Illuminate\Support\Facades\Storage;
+use ProcessMaker\Exception\BundleIntegrityException;
use ProcessMaker\ImportExport\Exporters\ScreenExporter;
use ProcessMaker\ImportExport\Logger;
use ProcessMaker\Models\Bundle;
@@ -17,6 +18,10 @@ class BundleTest extends TestCase
{
use HelperTrait;
+ private const ALPHA_DASHBOARD_NAME = 'Alpha Dashboard';
+
+ private const ALPHA_MENU_NAME = 'Alpha Menu';
+
public function testExport()
{
$this->addGlobalSignalProcess();
@@ -44,6 +49,52 @@ public function testExport()
$this->assertEquals($screen->title, $payload[1]['name']);
}
+ public function testExportRejectsBundleWithUnavailableAssetsBeforeExporting()
+ {
+ $bundle = Bundle::factory()->create(['name' => 'Corrupt Bundle']);
+ $screen = Screen::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Screen::class,
+ 'asset_id' => $screen->id,
+ ]);
+ $missingBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+ $unavailableBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ ]);
+
+ try {
+ $bundle->export();
+ $this->fail('Expected bundle integrity validation to fail.');
+ } catch (BundleIntegrityException $exception) {
+ $this->assertSame(
+ 'The bundle Corrupt Bundle contains unavailable assets and cannot be exported.',
+ $exception->getMessage()
+ );
+ $this->assertSame([
+ [
+ 'bundle_asset_id' => $missingBundleAsset->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ 'integrity_status' => BundleAsset::INTEGRITY_MISSING,
+ ],
+ [
+ 'bundle_asset_id' => $unavailableBundleAsset->id,
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ 'integrity_status' => BundleAsset::INTEGRITY_TYPE_UNAVAILABLE,
+ ],
+ ], $exception->invalidAssets());
+ }
+ }
+
public function testSyncAssets()
{
$screen1 = Screen::factory()->create(['title' => 'Screen 1']);
@@ -166,8 +217,8 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
$bundle->savePayloadsToFile([], [[
self::settingPayload('dashboard_package', 'dashboard-zulu', 'Zulu Dashboard'),
self::settingPayload('menu_package', 'menu-bravo', 'Bravo Menu'),
- self::settingPayload('dashboard_package', 'dashboard-alpha', 'Alpha Dashboard'),
- self::settingPayload('menu_package', 'menu-alpha', 'Alpha Menu'),
+ self::settingPayload('dashboard_package', 'dashboard-alpha', self::ALPHA_DASHBOARD_NAME),
+ self::settingPayload('menu_package', 'menu-alpha', self::ALPHA_MENU_NAME),
]]);
$this->assertTrue($bundle->newestVersionFile()->getCustomProperty('settings_payloads_complete'));
@@ -177,7 +228,7 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
'selection' => 'partial',
'available' => true,
'items' => [
- ['key' => 'dashboard-alpha', 'name' => 'Alpha Dashboard'],
+ ['key' => 'dashboard-alpha', 'name' => self::ALPHA_DASHBOARD_NAME],
['key' => 'dashboard-zulu', 'name' => 'Zulu Dashboard'],
],
], $bundle->settingPreview('ui_dashboards'));
@@ -187,7 +238,7 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
'selection' => 'all',
'available' => true,
'items' => [
- ['key' => 'menu-alpha', 'name' => 'Alpha Menu'],
+ ['key' => 'menu-alpha', 'name' => self::ALPHA_MENU_NAME],
['key' => 'menu-bravo', 'name' => 'Bravo Menu'],
],
], $bundle->settingPreview('ui_menus'));
@@ -213,8 +264,8 @@ public function testSettingPreviewIsUnavailableForUnmarkedLegacySnapshot()
$bundle->addSettings('ui_dashboards', null);
$bundle->addSettings('ui_menus', json_encode(['id' => [9001]]));
$bundle->addMediaFromString(gzencode(json_encode([
- self::settingPayload('dashboard_package', 'dashboard-alpha', 'Alpha Dashboard'),
- self::settingPayload('menu_package', 'menu-alpha', 'Alpha Menu'),
+ self::settingPayload('dashboard_package', 'dashboard-alpha', self::ALPHA_DASHBOARD_NAME),
+ self::settingPayload('menu_package', 'menu-alpha', self::ALPHA_MENU_NAME),
])))
->usingFileName('payloads.json.gz')
->withCustomProperties(['version' => $bundle->version])
diff --git a/tests/Model/DevLinkTest.php b/tests/Model/DevLinkTest.php
index dc6ca7e332..f0efaf12d3 100644
--- a/tests/Model/DevLinkTest.php
+++ b/tests/Model/DevLinkTest.php
@@ -4,6 +4,7 @@
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
+use ProcessMaker\Exception\DevLinkRemoteBundleException;
use ProcessMaker\Models\Bundle;
use ProcessMaker\Models\DevLink;
use ProcessMaker\Models\Screen;
@@ -23,6 +24,10 @@ class DevLinkTest extends TestCase
private const SECOND_MENU_DESCRIPTION = 'Second menu description';
+ private const LOCAL_BUNDLE_API_PATH = 'local-bundles/123';
+
+ private const EXPORT_LOCAL_BUNDLE_API_PATH = 'export-local-bundle/123';
+
public function testGetClientUrl()
{
$devLink = DevLink::factory()->create([
@@ -86,8 +91,8 @@ public function testInstallRemoteBundle()
$bundle->delete();
Http::fake([
- self::remoteApiUrl('local-bundles/123') => Http::response(self::remoteBundleResponse('5')),
- self::remoteApiUrl('export-local-bundle/123') => Http::response([
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::response(self::remoteBundleResponse('5')),
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::response([
'payloads' => $exports,
]),
self::remoteApiUrl('export-local-bundle/123/settings') => Http::response([
@@ -123,6 +128,40 @@ public function testInstallRemoteBundle()
$this->assertCount(3, $payloads);
}
+ public function testInstallRemoteBundleReportsUnavailableRemoteAssets()
+ {
+ Http::preventStrayRequests();
+ Http::fake([
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::response(self::remoteBundleResponse('5')),
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::response([
+ 'error' => [
+ 'code' => 422,
+ 'message' => 'The bundle contains unavailable assets.',
+ ],
+ 'errors' => [
+ 'assets' => [[
+ 'bundle_asset_id' => 34,
+ 'asset_type' => 'ProcessMaker\Plugins\Collections\Models\Collection',
+ 'asset_id' => 20,
+ 'integrity_status' => 'missing',
+ ]],
+ ],
+ ], 422),
+ ]);
+
+ $devLink = DevLink::factory()->create([
+ 'url' => self::REMOTE_INSTANCE_URL,
+ ]);
+
+ $this->expectException(DevLinkRemoteBundleException::class);
+ $this->expectExceptionMessage(
+ 'The remote bundle contains unavailable assets: Collection #20 (bundle asset #34). ' .
+ 'Repair the bundle on the source instance and try again.'
+ );
+
+ $devLink->installRemoteBundle(123, 'update');
+ }
+
public function testInstallRemoteBundleImportsAndReinstallsEverySelectedMenu()
{
if (!hasPackage('package-dynamic-ui')) {
@@ -310,13 +349,13 @@ public function testUpdateBundle()
]);
Http::fake([
- self::remoteApiUrl('local-bundles/123') => Http::sequence()
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::sequence()
->push(self::remoteBundleResponse('2'), 200)
->push(self::remoteBundleResponse('3'), 200)
->push(self::remoteBundleResponse('4'), 200)
->push(self::remoteBundleResponse('8'), 200)
->push(self::remoteBundleResponse('9'), 200),
- self::remoteApiUrl('export-local-bundle/123') => Http::sequence()
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::sequence()
->push([
'payloads' => $exports,
], 200)