From cbd031e7f15cf6e84bb300a55c20c0daf665b24d Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:11:23 +0000 Subject: [PATCH] fix(images): resolve nested definitions and versions through their relations The admin API is registered with scopeBindings(), which resolves a child route parameter through a parent relation named after it: `{image_definition}` under `{image_group}` through ImageGroup::imageDefinitions(), and `{image_version}` under `{image_definition}` through ImageDefinition::imageVersions(). Neither exists -- the relations are definitions() and versions() -- so every route below /image-groups/{group}/images/{definition} answered 500, taking the Versions dialog, image edit and version publishing with it. Map those two parameters onto the existing relations, and cover the routes over HTTP, including a child requested under the wrong parent still 404ing. --- app/Models/ImageDefinition.php | 11 +++ app/Models/ImageGroup.php | 12 +++ .../Feature/Images/ImageRouteBindingTest.php | 84 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 tests/Feature/Images/ImageRouteBindingTest.php diff --git a/app/Models/ImageDefinition.php b/app/Models/ImageDefinition.php index 89a30f80ec9..00e30040cdf 100644 --- a/app/Models/ImageDefinition.php +++ b/app/Models/ImageDefinition.php @@ -58,6 +58,17 @@ public function getRouteKeyName(): string return 'uuid'; } + /** + * `{image_version}` under `{image_definition}` is resolved through a relation + * named after the parameter (see ImageGroup), and here that is `versions()`. + */ + protected function childRouteBindingRelationshipName($childType): string + { + return $childType === 'image_version' + ? 'versions' + : parent::childRouteBindingRelationshipName($childType); + } + /** * @return BelongsTo */ diff --git a/app/Models/ImageGroup.php b/app/Models/ImageGroup.php index 55973979f73..ac8e3ce39e4 100644 --- a/app/Models/ImageGroup.php +++ b/app/Models/ImageGroup.php @@ -46,6 +46,18 @@ public function getRouteKeyName(): string return 'uuid'; } + /** + * The admin API is registered with scopeBindings(), so `{image_definition}` + * under `{image_group}` is resolved through a relation named after the + * parameter -- `imageDefinitions()`, which is `definitions()` here. + */ + protected function childRouteBindingRelationshipName($childType): string + { + return $childType === 'image_definition' + ? 'definitions' + : parent::childRouteBindingRelationshipName($childType); + } + /** * @return HasMany */ diff --git a/tests/Feature/Images/ImageRouteBindingTest.php b/tests/Feature/Images/ImageRouteBindingTest.php new file mode 100644 index 00000000000..ed4459ee6df --- /dev/null +++ b/tests/Feature/Images/ImageRouteBindingTest.php @@ -0,0 +1,84 @@ + $name]); + $definition = ImageDefinition::create([ + 'image_group_id' => $group->id, + 'name' => "{$name} 1", + 'ostype' => 'l26', + ]); + $version = $definition->versions()->create([ + 'version' => '1.0.0', + 'disks' => [[ + 'slot' => 'scsi0', + 'role' => ImageDiskRole::SYSTEM->value, + 'url' => 'https://example.invalid/disk.qcow2', + 'path' => null, + 'sha256' => str_repeat('b', 64), + 'size' => 1024, + 'virtual_size' => 2048, + 'format' => 'qcow2', + ]], + ]); + + return [$group, $definition, $version]; +} + +it('shows a definition through its group', function () { + [$group, $definition] = imageBindingChain('Debian'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}") + ->assertOk() + ->assertJsonPath('data.uuid', $definition->uuid); +}); + +it('lists and shows a definition’s versions', function () { + [$group, $definition, $version] = imageBindingChain('Debian'); + $base = "/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}/versions"; + + $this->actingAs(admin()) + ->getJson($base) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.uuid', $version->uuid); + + $this->actingAs(admin()) + ->getJson("{$base}/{$version->uuid}") + ->assertOk() + ->assertJsonPath('data.uuid', $version->uuid); +}); + +it('does not find a definition under another group', function () { + [$group] = imageBindingChain('Debian'); + [, $elsewhere] = imageBindingChain('Ubuntu'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$elsewhere->uuid}") + ->assertNotFound(); +}); + +it('does not find a version under another definition', function () { + [$group, $definition] = imageBindingChain('Debian'); + [, , $elsewhere] = imageBindingChain('Ubuntu'); + + $this->actingAs(admin()) + ->getJson("/api/admin/image-groups/{$group->uuid}/images/{$definition->uuid}/versions/{$elsewhere->uuid}") + ->assertNotFound(); +});