diff --git a/app/Support/PostMediaRules.php b/app/Support/PostMediaRules.php index 001aa1699..b2200e378 100644 --- a/app/Support/PostMediaRules.php +++ b/app/Support/PostMediaRules.php @@ -27,6 +27,16 @@ class PostMediaRules */ public const ALT_TEXT_MAX_LENGTH = 2000; + /** + * Workspace media collections a post may reference by id: uploaded/library + * assets, and slides or images the AI pipeline generated for this workspace + * (PostImagePipeline, RegeneratePostMediaImage). Other collections such as + * `logo` or `avatar` are not post media and stay rejected. + * + * @var array + */ + public const POST_MEDIA_COLLECTIONS = ['assets', 'ai-generated']; + /** * @param bool $hosted true (web): items must already be hosted (id + path * required); false (API): a bare external `url` is @@ -121,7 +131,7 @@ public static function assertHostedMediaExists(Validator $validator, Workspace $ $exists = Media::query() ->where('mediable_type', Relation::getMorphAlias(Workspace::class)) ->where('mediable_id', $workspace->id) - ->where('collection', 'assets') + ->whereIn('collection', self::POST_MEDIA_COLLECTIONS) ->whereKey($id) ->exists(); diff --git a/tests/Feature/PostMediaExistsValidationWebTest.php b/tests/Feature/PostMediaExistsValidationWebTest.php index d82ab5ab8..d80fe49bb 100644 --- a/tests/Feature/PostMediaExistsValidationWebTest.php +++ b/tests/Feature/PostMediaExistsValidationWebTest.php @@ -108,3 +108,65 @@ expect($post->fresh()->media)->toHaveCount(1) ->and(data_get($post->fresh()->media, '0.id'))->toBe($asset->id); }); + +test('update post accepts media generated by the AI image pipeline (ai-generated collection)', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $slide = Media::factory()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'collection' => 'ai-generated', + ]); + + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'media' => [['id' => $slide->id, 'path' => $slide->path, 'url' => 'https://example.com/'.$slide->path, 'type' => 'image', 'source' => 'ai']], + ]); + + $response->assertSessionDoesntHaveErrors(); + expect(data_get($post->fresh()->media, '0.id'))->toBe($slide->id); +}); + +test('update post rejects another workspace\'s ai-generated media (cross-tenant IDOR)', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $other = Workspace::factory()->create(); + $foreignSlide = Media::factory()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $other->id, + 'collection' => 'ai-generated', + ]); + + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'media' => [['id' => $foreignSlide->id, 'path' => $foreignSlide->path, 'url' => 'https://example.com/'.$foreignSlide->path, 'type' => 'image']], + ]); + + $response->assertSessionHasErrors(['media.0.id']); + expect($post->fresh()->media)->toBe([]); +}); + +test('update post still rejects the workspace logo collection as post media', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $logo = Media::factory()->logo()->create([ + 'mediable_type' => (new Workspace)->getMorphClass(), + 'mediable_id' => $this->workspace->id, + ]); + + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'media' => [['id' => $logo->id, 'path' => $logo->path, 'url' => 'https://example.com/'.$logo->path, 'type' => 'image']], + ]); + + $response->assertSessionHasErrors(['media.0.id']); +});