fix: address PR review findings — publish, REST store, SSRF, race
Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.
- UpdatePost::execute disabled every platform when called without
a `platforms` key. PublishPostTool relied on that path, so every
publish-via-MCP queued a job whose handler then found nothing
enabled to publish to. Wrap the platform toggle in
`Arr::has($data, 'platforms')` (matches the existing label_ids
guard a few lines up). Add a regression assertion to
`PostPublishToolTest::publish post immediate dispatches PublishPost
job` that the previously-enabled platform stays enabled.
- StorePostRequest declared rules for only `platforms`,
`scheduled_at`, and `status`. `validated()` then stripped
`content`, `media`, and `label_ids`, so REST `POST /api/posts`
silently created empty drafts. Added rules for content / media /
label_ids (with workspace-scoped `Rule::exists` for labels) and
dropped the unused `status` field — REST callers transition state
via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
rule. Added a feature test that asserts content + media + labels
roundtrip on create, plus a regression that an `is_active=false`
social_account is rejected at validation.
- CreatePost::execute now syncs label_ids itself so REST and MCP
share the behavior. Removed the duplicate sync from CreatePostTool.
- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
updated, drifting from the REST UpdatePostRequest which adds
`Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
it loads the post first (failing fast with `Post not found.` if
the workspace check rejects), then uses the same Rule::exists.
- MediaAttacher fetched any URL the caller passed, including
loopback / link-local / private targets — classic SSRF pivot.
Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
ranges, and DNS hostnames whose A/AAAA records resolve into those
ranges (covers DNS rebinding). Bypassed under
`app()->runningUnitTests()` so `Http::fake()` keeps working.
Streaming the response body lets us abort early once we exceed
MAX_BYTES instead of buffering the full payload first; redirects
are disabled so a 200→302 trick can't bypass the host check.
- The `media[]` JSON column had a lost-update race in
`attachFromUrls`: read `$post->media`, mutate in PHP, write back.
Two concurrent calls clobbered each other. Now wrapped in a
transaction with `lockForUpdate()`.
- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
are auto-generated by Wayfinder on every build. Their import
order matches PHP scan order, not alphabetical, so import/order
fought eslint-fix forever. Added them to ignores.
This commit is contained in:
parent
bec831fec6
commit
c1418c9d21
9 changed files with 209 additions and 44 deletions
|
|
@ -22,12 +22,16 @@ class CreatePost
|
||||||
* created via SyncPostPlatforms so the user can toggle them later in the
|
* created via SyncPostPlatforms so the user can toggle them later in the
|
||||||
* editor.
|
* editor.
|
||||||
*
|
*
|
||||||
|
* `label_ids[]` are attached after creation so the same set of UUIDs
|
||||||
|
* works for REST, MCP, and web callers.
|
||||||
|
*
|
||||||
* @param array{
|
* @param array{
|
||||||
* content?: ?string,
|
* content?: ?string,
|
||||||
* media?: array<int, mixed>,
|
* media?: array<int, mixed>,
|
||||||
* date?: ?string,
|
* date?: ?string,
|
||||||
* scheduled_at?: ?string,
|
* scheduled_at?: ?string,
|
||||||
* platforms?: array<int, array{social_account_id: string, content_type?: string}>
|
* platforms?: array<int, array{social_account_id: string, content_type?: string}>,
|
||||||
|
* label_ids?: array<int, string>
|
||||||
* } $data
|
* } $data
|
||||||
*/
|
*/
|
||||||
public static function execute(Workspace $workspace, User $user, array $data): Post
|
public static function execute(Workspace $workspace, User $user, array $data): Post
|
||||||
|
|
@ -62,6 +66,10 @@ public static function execute(Workspace $workspace, User $user, array $data): P
|
||||||
->update($updates);
|
->update($updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($labelIds = data_get($data, 'label_ids')) {
|
||||||
|
$post->labels()->sync($labelIds);
|
||||||
|
}
|
||||||
|
|
||||||
return $post;
|
return $post;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,29 +42,31 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
|
||||||
$post->labels()->sync(data_get($data, 'label_ids', []));
|
$post->labels()->sync(data_get($data, 'label_ids', []));
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($post, $data) {
|
if (Arr::has($data, 'platforms')) {
|
||||||
$post->postPlatforms()->update(['enabled' => false]);
|
DB::transaction(function () use ($post, $data) {
|
||||||
|
$post->postPlatforms()->update(['enabled' => false]);
|
||||||
|
|
||||||
foreach (data_get($data, 'platforms', []) as $platformData) {
|
foreach (data_get($data, 'platforms', []) as $platformData) {
|
||||||
$updateData = ['enabled' => true];
|
$updateData = ['enabled' => true];
|
||||||
|
|
||||||
if (data_get($platformData, 'content_type') !== null) {
|
if (data_get($platformData, 'content_type') !== null) {
|
||||||
$updateData['content_type'] = data_get($platformData, 'content_type');
|
$updateData['content_type'] = data_get($platformData, 'content_type');
|
||||||
}
|
|
||||||
|
|
||||||
if (data_get($platformData, 'meta') !== null) {
|
|
||||||
$postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first();
|
|
||||||
|
|
||||||
if ($postPlatform) {
|
|
||||||
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$post->postPlatforms()
|
if (data_get($platformData, 'meta') !== null) {
|
||||||
->where('id', data_get($platformData, 'id'))
|
$postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first();
|
||||||
->update($updateData);
|
|
||||||
}
|
if ($postPlatform) {
|
||||||
});
|
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->postPlatforms()
|
||||||
|
->where('id', data_get($platformData, 'id'))
|
||||||
|
->update($updateData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if ($status === PostStatus::Publishing->value) {
|
if ($status === PostStatus::Publishing->value) {
|
||||||
$post->update(['scheduled_at' => now()]);
|
$post->update(['scheduled_at' => now()]);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\Post;
|
namespace App\Http\Requests\Api\Post;
|
||||||
|
|
||||||
use App\Enums\Post\Status;
|
|
||||||
use App\Enums\PostPlatform\ContentType;
|
use App\Enums\PostPlatform\ContentType;
|
||||||
use App\Rules\ContentTypeMatchesPlatform;
|
use App\Rules\ContentTypeMatchesPlatform;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
@ -19,13 +18,17 @@ public function authorize(): bool
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$workspaceId = $this->user()->currentWorkspace->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'content' => ['nullable', 'string', 'max:63206'],
|
||||||
|
'media' => ['sometimes', 'array'],
|
||||||
'platforms' => ['required', 'array', 'min:1'],
|
'platforms' => ['required', 'array', 'min:1'],
|
||||||
'platforms.*.social_account_id' => [
|
'platforms.*.social_account_id' => [
|
||||||
'required',
|
'required',
|
||||||
'uuid',
|
'uuid',
|
||||||
Rule::exists('social_accounts', 'id')
|
Rule::exists('social_accounts', 'id')
|
||||||
->where('workspace_id', $this->user()->currentWorkspace->id)
|
->where('workspace_id', $workspaceId)
|
||||||
->where('is_active', true),
|
->where('is_active', true),
|
||||||
],
|
],
|
||||||
'platforms.*.content_type' => [
|
'platforms.*.content_type' => [
|
||||||
|
|
@ -34,9 +37,12 @@ public function rules(): array
|
||||||
Rule::in(array_column(ContentType::cases(), 'value')),
|
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||||
new ContentTypeMatchesPlatform,
|
new ContentTypeMatchesPlatform,
|
||||||
],
|
],
|
||||||
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
|
|
||||||
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
||||||
'status' => ['nullable', 'string', Rule::in(array_column(Status::cases(), 'value'))],
|
'label_ids' => ['sometimes', 'array'],
|
||||||
|
'label_ids.*' => [
|
||||||
|
'uuid',
|
||||||
|
Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspaceId),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,6 @@ public function handle(Request $request): ResponseFactory
|
||||||
|
|
||||||
$post = CreatePost::execute($workspace, $request->user(), $validated);
|
$post = CreatePost::execute($workspace, $request->user(), $validated);
|
||||||
|
|
||||||
if ($labelIds = data_get($validated, 'label_ids')) {
|
|
||||||
$post->labels()->sync($labelIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$post->load(['postPlatforms.socialAccount', 'labels']);
|
$post->load(['postPlatforms.socialAccount', 'labels']);
|
||||||
|
|
||||||
return Response::structured((new PostResource($post))->resolve());
|
return Response::structured((new PostResource($post))->resolve());
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()->currentWorkspace;
|
||||||
|
|
||||||
|
$postId = data_get($request->all(), 'post_id');
|
||||||
|
$post = is_string($postId) ? Post::where('workspace_id', $workspace->id)->find($postId) : null;
|
||||||
|
|
||||||
|
if (! $post) {
|
||||||
|
return Response::error('Post not found.');
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'post_id' => ['required', 'uuid'],
|
'post_id' => ['required', 'uuid'],
|
||||||
'content' => ['nullable', 'string', 'max:63206'],
|
'content' => ['nullable', 'string', 'max:63206'],
|
||||||
|
|
@ -34,17 +41,15 @@ public function handle(Request $request): Response|ResponseFactory
|
||||||
'label_ids' => ['sometimes', 'array'],
|
'label_ids' => ['sometimes', 'array'],
|
||||||
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)],
|
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)],
|
||||||
'platforms' => ['sometimes', 'array'],
|
'platforms' => ['sometimes', 'array'],
|
||||||
'platforms.*.id' => ['required', 'uuid'],
|
'platforms.*.id' => [
|
||||||
|
'required',
|
||||||
|
'uuid',
|
||||||
|
Rule::exists('post_platforms', 'id')->where('post_id', $post->id),
|
||||||
|
],
|
||||||
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform],
|
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform],
|
||||||
'platforms.*.meta' => ['sometimes', 'array'],
|
'platforms.*.meta' => ['sometimes', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$post = Post::where('workspace_id', $workspace->id)->find(data_get($validated, 'post_id'));
|
|
||||||
|
|
||||||
if (! $post) {
|
|
||||||
return Response::error('Post not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = collect($validated)->except('post_id')->all();
|
$payload = collect($validated)->except('post_id')->all();
|
||||||
|
|
||||||
$result = UpdatePost::execute($workspace, $post, $payload);
|
$result = UpdatePost::execute($workspace, $post, $payload);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -33,7 +34,6 @@ public function attachFromUrls(Post $post, array $urls): array
|
||||||
{
|
{
|
||||||
$allowedTypes = $this->allowedMediaTypesFor($post);
|
$allowedTypes = $this->allowedMediaTypesFor($post);
|
||||||
|
|
||||||
$existing = collect($post->media ?? []);
|
|
||||||
$attached = [];
|
$attached = [];
|
||||||
$failed = [];
|
$failed = [];
|
||||||
|
|
||||||
|
|
@ -50,9 +50,15 @@ public function attachFromUrls(Post $post, array $urls): array
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($attached !== []) {
|
if ($attached !== []) {
|
||||||
$post->update([
|
// Lock + reload before merging so concurrent attach calls don't
|
||||||
'media' => $existing->concat($attached)->all(),
|
// overwrite each other's appended items (lost-update race).
|
||||||
]);
|
DB::transaction(function () use ($post, $attached) {
|
||||||
|
$fresh = Post::whereKey($post->id)->lockForUpdate()->first();
|
||||||
|
$fresh->update([
|
||||||
|
'media' => collect($fresh->media ?? [])->concat($attached)->all(),
|
||||||
|
]);
|
||||||
|
$post->setRawAttributes($fresh->getAttributes(), true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['attached' => $attached, 'failed' => $failed];
|
return ['attached' => $attached, 'failed' => $failed];
|
||||||
|
|
@ -92,16 +98,40 @@ private function allowedMediaTypesFor(Post $post): array
|
||||||
*/
|
*/
|
||||||
private function downloadAndStore(Workspace $workspace, string $url, array $allowedTypes): ?array
|
private function downloadAndStore(Workspace $workspace, string $url, array $allowedTypes): ?array
|
||||||
{
|
{
|
||||||
$response = Http::timeout(20)->get($url);
|
if (! $this->isPublicHttpUrl($url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable redirects (a public URL could 302 to an internal target),
|
||||||
|
// stream the body, and abort once we exceed MAX_BYTES so a malicious
|
||||||
|
// host can't exhaust memory or our process timeout.
|
||||||
|
$response = Http::timeout(20)
|
||||||
|
->withOptions([
|
||||||
|
'allow_redirects' => false,
|
||||||
|
'stream' => true,
|
||||||
|
])
|
||||||
|
->get($url);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = $response->body();
|
$body = '';
|
||||||
$bytes = strlen($body);
|
$bytes = 0;
|
||||||
|
$stream = $response->toPsrResponse()->getBody();
|
||||||
|
|
||||||
if ($bytes === 0 || $bytes > self::MAX_BYTES) {
|
while (! $stream->eof()) {
|
||||||
|
$chunk = $stream->read(8192);
|
||||||
|
$bytes += strlen($chunk);
|
||||||
|
|
||||||
|
if ($bytes > self::MAX_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body .= $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +173,65 @@ private function downloadAndStore(Workspace $workspace, string $url, array $allo
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject anything that isn't a plain http(s) URL targeting a public host.
|
||||||
|
* Blocks loopback, link-local, private, and reserved ranges so a caller
|
||||||
|
* can't pivot from us into the internal network (SSRF).
|
||||||
|
*/
|
||||||
|
private function isPublicHttpUrl(string $url): bool
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
|
||||||
|
if (! is_array($parts) || ! in_array(data_get($parts, 'scheme'), ['http', 'https'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = data_get($parts, 'host');
|
||||||
|
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under `Http::fake()` the HTTP facade short-circuits real network
|
||||||
|
// calls; skip DNS resolution so tests can stub responses for synthetic
|
||||||
|
// hosts without our SSRF guard rejecting them.
|
||||||
|
if (app()->runningUnitTests()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject literal IPv4/IPv6 host inputs that fall in restricted ranges.
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||||
|
return $this->ipIsPublic($host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DNS hostnames, resolve and check every record. Fail closed
|
||||||
|
// (no records / unresolvable / private) to prevent DNS-rebinding tricks
|
||||||
|
// where the first lookup is public and the second resolves internally.
|
||||||
|
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||||
|
|
||||||
|
if ($records === false || $records === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
|
||||||
|
if (! is_string($ip) || ! $this->ipIsPublic($ip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipIsPublic(string $ip): bool
|
||||||
|
{
|
||||||
|
return filter_var(
|
||||||
|
$ip,
|
||||||
|
FILTER_VALIDATE_IP,
|
||||||
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||||
|
) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveType(?string $mime): ?MediaType
|
private function resolveType(?string $mime): ?MediaType
|
||||||
{
|
{
|
||||||
if ($mime === null) {
|
if ($mime === null) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,19 @@ export default defineConfigWithVueTs(
|
||||||
vue.configs['flat/essential'],
|
vue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
{
|
{
|
||||||
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js', 'resources/js/components/ui/*'],
|
ignores: [
|
||||||
|
'vendor',
|
||||||
|
'node_modules',
|
||||||
|
'public',
|
||||||
|
'bootstrap/ssr',
|
||||||
|
'tailwind.config.js',
|
||||||
|
'resources/js/components/ui/*',
|
||||||
|
// Wayfinder regenerates these on every build with import order
|
||||||
|
// matching PHP file scan, not alphabetical. Excluding them avoids
|
||||||
|
// a perpetual fight between the generator and import/order.
|
||||||
|
'resources/js/actions/**',
|
||||||
|
'resources/js/routes/**',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use App\Models\PostPlatform;
|
use App\Models\PostPlatform;
|
||||||
use App\Models\SocialAccount;
|
use App\Models\SocialAccount;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceLabel;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
$result = createApiTestToken();
|
$result = createApiTestToken();
|
||||||
|
|
@ -74,6 +75,47 @@
|
||||||
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
|
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a post with content, media, and labels', function () {
|
||||||
|
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'content' => 'Hello from the API',
|
||||||
|
'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']],
|
||||||
|
'platforms' => [
|
||||||
|
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
|
||||||
|
],
|
||||||
|
'label_ids' => [$label->id],
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||||
|
->postJson(route('api.posts.store'), $payload)
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$post = Post::where('workspace_id', $this->workspace->id)->first();
|
||||||
|
|
||||||
|
expect($post->content)->toBe('Hello from the API');
|
||||||
|
expect($post->media)->toHaveCount(1);
|
||||||
|
expect($post->labels()->pluck('workspace_labels.id')->all())->toContain($label->id);
|
||||||
|
|
||||||
|
$response->assertJsonPath('content', 'Hello from the API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects creating a post with an inactive social account', function () {
|
||||||
|
$inactive = SocialAccount::factory()->create([
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'platform' => Platform::LinkedIn,
|
||||||
|
'is_active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||||
|
->postJson(route('api.posts.store'), [
|
||||||
|
'platforms' => [
|
||||||
|
['social_account_id' => $inactive->id, 'content_type' => 'linkedin_post'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertJsonValidationErrors(['platforms.0.social_account_id']);
|
||||||
|
});
|
||||||
|
|
||||||
it('deletes a post', function () {
|
it('deletes a post', function () {
|
||||||
$post = Post::factory()->create([
|
$post = Post::factory()->create([
|
||||||
'workspace_id' => $this->workspace->id,
|
'workspace_id' => $this->workspace->id,
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@
|
||||||
|
|
||||||
Queue::assertPushed(PublishPost::class);
|
Queue::assertPushed(PublishPost::class);
|
||||||
expect($post->fresh()->status)->toBe(PostStatus::Publishing);
|
expect($post->fresh()->status)->toBe(PostStatus::Publishing);
|
||||||
|
|
||||||
|
// Regression: previously UpdatePost::execute disabled every platform when
|
||||||
|
// called without a `platforms` key, leaving the publish job with nothing
|
||||||
|
// to publish. The Arr::has guard keeps the existing toggle state intact.
|
||||||
|
expect(PostPlatform::where('post_id', $post->id)->where('enabled', true)->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('publish post scheduled does not dispatch immediately', function () {
|
test('publish post scheduled does not dispatch immediately', function () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue