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:
parent
26d0b2cdb2
commit
459f4dd5a5
8 changed files with 79 additions and 3 deletions
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
31
tests/Feature/Actions/Post/DuplicatePostTest.php
Normal file
31
tests/Feature/Actions/Post/DuplicatePostTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue