Harden created_via: require it, cover duplicate and all entry points.

Reject CreatePost calls without CreatedVia, set Web on DuplicatePost, and assert wiring for templates, AI, automation, and API spoof attempts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 11:30:15 -03:00
parent 26d0b2cdb2
commit 459f4dd5a5
8 changed files with 79 additions and 3 deletions

View file

@ -12,6 +12,7 @@
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class CreatePost
{
@ -39,18 +40,26 @@ class CreatePost
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
* label_ids?: array<int, string>
* } $data
*
* @throws InvalidArgumentException when created_via is missing or not a CreatedVia case
*/
public static function execute(Workspace $workspace, User $user, array $data): Post
{
$createdVia = data_get($data, 'created_via');
if (! $createdVia instanceof CreatedVia) {
throw new InvalidArgumentException('created_via must be a CreatedVia enum case.');
}
$scheduledAt = self::resolveScheduledAt($data);
$post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post {
$post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt, $createdVia): Post {
$post = $workspace->posts()->create([
'user_id' => $user->id,
'content' => data_get($data, 'content', ''),
'media' => data_get($data, 'media', []),
'status' => PostStatus::Draft,
'created_via' => data_get($data, 'created_via'),
'created_via' => $createdVia,
'scheduled_at' => $scheduledAt,
]);

View file

@ -4,6 +4,7 @@
namespace App\Actions\Post;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
@ -25,6 +26,7 @@ public static function execute(Post $original, User $user): Post
'content' => $original->content,
'media' => $original->media,
'status' => PostStatus::Draft,
'created_via' => CreatedVia::Web,
'scheduled_at' => null,
'published_at' => null,
]);

View file

@ -43,3 +43,12 @@
'api' => CreatedVia::Api,
'automation' => CreatedVia::Automation,
]);
test('execute requires created_via', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
CreatePost::execute($workspace, $user, [
'content' => 'Hello world',
]);
})->throws(InvalidArgumentException::class, 'created_via must be a CreatedVia enum case.');

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Actions\Post\DuplicatePost;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
test('execute clones the post as a draft created via web', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$original = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'Original content',
'created_via' => CreatedVia::Api,
'status' => PostStatus::Published,
]);
$copy = DuplicatePost::execute($original, $user);
expect($copy->id)->not->toBe($original->id)
->and($copy->content)->toBe('Original content')
->and($copy->status)->toBe(PostStatus::Draft)
->and($copy->created_via)->toBe(CreatedVia::Web)
->and($copy->scheduled_at)->toBeNull()
->and($copy->published_at)->toBeNull();
});

View file

@ -4,6 +4,7 @@
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Enums\UserWorkspace\Role;
use App\Jobs\Ai\StreamPostCreation;
@ -111,7 +112,8 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('Hello world\n\nSecond para.')
->and($post->media)->toHaveCount(1);
->and($post->media)->toHaveCount(1)
->and($post->created_via)->toBe(CreatedVia::Web);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();

View file

@ -78,6 +78,23 @@
expect($post->created_via)->toBe(CreatedVia::Api);
});
it('ignores a client-supplied created_via and always records api', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'created_via' => 'web',
'platforms' => [
[
'social_account_id' => $this->socialAccount->id,
'content_type' => 'linkedin_post',
],
],
])
->assertCreated();
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post->created_via)->toBe(CreatedVia::Api);
});
it('creates a post with content, media, and labels', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);

View file

@ -7,6 +7,7 @@
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
@ -47,6 +48,7 @@
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->status)->toBe(PostStatus::Draft);
expect($post->created_via)->toBe(CreatedVia::Automation);
});
it('applies brand voice by default', function () {

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\SocialAccount;
@ -72,6 +73,9 @@
expect($response->json('post_id'))->toBeString();
expect($response->json('redirect_url'))->toContain('edit');
$post = $this->workspace->posts()->find($response->json('post_id'));
expect($post->created_via)->toBe(CreatedVia::Web);
});
test('apply interpolates brand_name in content', function () {