Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/Support/PostMediaRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string>
*/
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
Expand Down Expand Up @@ -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();

Expand Down
62 changes: 62 additions & 0 deletions tests/Feature/PostMediaExistsValidationWebTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});