Validate webhook payload template is JSON before it can run
A webhook node parses its payload template as JSON before resolving
placeholders, so a template with unquoted {{ }} placeholders or any malformed
JSON could be saved, tested, and activated — only to fail midway through a run.
Reject it up front instead: AutomationConfigValidator is the single source of
truth for per-node config issues (keyed to the field the editor surfaces them
under), enforced on save (field errors), on activate, and before a test run.
The editor mirrors the check to disable Test/Activate with a clear reason, and
the test panel now surfaces the server's message instead of a generic toast.
This commit is contained in:
parent
867c8d8acf
commit
2bd2e72656
14 changed files with 370 additions and 51 deletions
|
|
@ -7,12 +7,12 @@
|
|||
use App\Enums\Automation\Node\Type as NodeType;
|
||||
use App\Enums\Automation\Status;
|
||||
use App\Models\Automation;
|
||||
use App\Services\Automation\GenerateNodeValidator;
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use DomainException;
|
||||
|
||||
class ActivateAutomation
|
||||
{
|
||||
public function __construct(private GenerateNodeValidator $generateValidator) {}
|
||||
public function __construct(private AutomationConfigValidator $configValidator) {}
|
||||
|
||||
public function __invoke(Automation $automation): Automation
|
||||
{
|
||||
|
|
@ -32,7 +32,7 @@ private function validate(Automation $automation): void
|
|||
$nodes = $automation->nodes ?? [];
|
||||
$connections = $automation->connections ?? [];
|
||||
|
||||
$triggers = collect($nodes)->where('type', 'trigger');
|
||||
$triggers = collect($nodes)->where('type', NodeType::Trigger->value);
|
||||
if ($triggers->count() !== 1) {
|
||||
throw new DomainException(__('automations.errors.must_have_one_trigger'));
|
||||
}
|
||||
|
|
@ -43,16 +43,10 @@ private function validate(Automation $automation): void
|
|||
throw new DomainException(__('automations.errors.trigger_must_be_connected'));
|
||||
}
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
if (data_get($node, 'type') !== NodeType::Generate->value) {
|
||||
continue;
|
||||
}
|
||||
$issue = $this->configValidator->firstMessage($nodes);
|
||||
|
||||
$issue = $this->generateValidator->issueFor((array) data_get($node, 'data', []));
|
||||
|
||||
if ($issue !== null) {
|
||||
throw new DomainException($issue);
|
||||
}
|
||||
if ($issue !== null) {
|
||||
throw new DomainException($issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
// Parse the template as JSON FIRST, then resolve placeholders in its
|
||||
// string leaves — so a value containing `"`/`&`/newlines can't corrupt
|
||||
// the JSON (the final json_encode escapes it).
|
||||
$template = $config['payload_template'] ?? '{}';
|
||||
$template = (string) data_get($config, 'payload_template', '{}');
|
||||
$trimmedTemplate = trim($template);
|
||||
|
||||
if ($trimmedTemplate === '' || $trimmedTemplate === 'null') {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Post;
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Kicks off a manual run from the editor without waiting for the real trigger
|
||||
|
|
@ -25,10 +27,19 @@
|
|||
*/
|
||||
class TestAutomation
|
||||
{
|
||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
||||
public function __construct(
|
||||
private AdvanceAutomationRun $advance,
|
||||
private AutomationConfigValidator $configValidator,
|
||||
) {}
|
||||
|
||||
public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun
|
||||
{
|
||||
$issue = $this->configValidator->firstMessage($automation->nodes ?? []);
|
||||
|
||||
if ($issue !== null) {
|
||||
throw new DomainException($issue);
|
||||
}
|
||||
|
||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
||||
$context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])];
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Enums\Automation\Publish\Mode as PublishMode;
|
||||
use App\Enums\Automation\ScheduleField;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use App\Services\Automation\GenerateNodeValidator;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
|
@ -66,9 +67,10 @@ public function rules(): array
|
|||
}
|
||||
|
||||
/**
|
||||
* Block saving a Generate node whose intended image count doesn't fit a
|
||||
* selected account's content-type (mirrors the inline frontend validation),
|
||||
* keyed so the frontend surfaces it under that node's accounts field.
|
||||
* Block saving a node whose config can't run: a Generate node whose image
|
||||
* count doesn't fit a selected account's content-type, or a Webhook node
|
||||
* whose payload template isn't valid JSON. Each issue is keyed to the field
|
||||
* the frontend surfaces it under (mirrors the inline frontend validation).
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
|
|
@ -79,18 +81,8 @@ public function withValidator(Validator $validator): void
|
|||
return;
|
||||
}
|
||||
|
||||
$generateValidator = app(GenerateNodeValidator::class);
|
||||
|
||||
foreach ($nodes as $i => $node) {
|
||||
if (data_get($node, 'type') !== NodeType::Generate->value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$issue = $generateValidator->issueFor((array) data_get($node, 'data', []));
|
||||
|
||||
if ($issue !== null) {
|
||||
$validator->errors()->add("nodes.{$i}.data.accounts", $issue);
|
||||
}
|
||||
foreach (app(AutomationConfigValidator::class)->issues($nodes) as $issue) {
|
||||
$validator->errors()->add("nodes.{$issue['node_index']}.data.{$issue['field']}", $issue['message']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
59
app/Services/Automation/AutomationConfigValidator.php
Normal file
59
app/Services/Automation/AutomationConfigValidator.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Automation;
|
||||
|
||||
use App\Enums\Automation\Node\Type as NodeType;
|
||||
|
||||
/**
|
||||
* Single source of truth for per-node config validation. Walks an automation's
|
||||
* nodes and reports every config issue, delegating to the type-specific
|
||||
* validators. Shared by save (field-keyed errors), activation, and the editor
|
||||
* test run so a misconfigured node is rejected up front with a clear message
|
||||
* instead of failing midway through execution.
|
||||
*/
|
||||
final class AutomationConfigValidator
|
||||
{
|
||||
public function __construct(
|
||||
private GenerateNodeValidator $generateValidator,
|
||||
private WebhookNodeValidator $webhookValidator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every config issue across the given nodes, in node order.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
* @return list<array{node_index: int, field: string, message: string}>
|
||||
*/
|
||||
public function issues(array $nodes): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
foreach ($nodes as $index => $node) {
|
||||
$config = (array) data_get($node, 'data', []);
|
||||
|
||||
[$field, $message] = match (data_get($node, 'type')) {
|
||||
NodeType::Generate->value => ['accounts', $this->generateValidator->issueFor($config)],
|
||||
NodeType::Webhook->value => ['payload_template', $this->webhookValidator->issueFor($config)],
|
||||
default => [null, null],
|
||||
};
|
||||
|
||||
if ($message !== null) {
|
||||
$issues[] = ['node_index' => $index, 'field' => $field, 'message' => $message];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first config issue's message, or null when every node is runnable.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
*/
|
||||
public function firstMessage(array $nodes): ?string
|
||||
{
|
||||
return $this->issues($nodes)[0]['message'] ?? null;
|
||||
}
|
||||
}
|
||||
36
app/Services/Automation/WebhookNodeValidator.php
Normal file
36
app/Services/Automation/WebhookNodeValidator.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Automation;
|
||||
|
||||
/**
|
||||
* Backend mirror of the Webhook node's payload-template contract: the template
|
||||
* is parsed as JSON before placeholders are resolved (see RunWebhookNode), so a
|
||||
* template with unquoted `{{ }}` placeholders or any malformed JSON can never
|
||||
* run. An empty or literal-`null` template means "no body" and is valid.
|
||||
*/
|
||||
final class WebhookNodeValidator
|
||||
{
|
||||
/**
|
||||
* First compliance issue for a webhook node's config, or null when valid.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function issueFor(array $config): ?string
|
||||
{
|
||||
$template = trim((string) data_get($config, 'payload_template', ''));
|
||||
|
||||
if ($template === '' || $template === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
json_decode($template);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return __('automations.errors.webhook_invalid_payload_json');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type { AutomationVariable } from '@/types/automation/automation';
|
|||
defineProps<{
|
||||
automationId: string;
|
||||
beforeRun?: () => Promise<boolean> | boolean;
|
||||
configIssue?: string | null;
|
||||
editing?: boolean;
|
||||
nodeTitle?: string;
|
||||
deletable?: boolean;
|
||||
|
|
@ -63,7 +64,7 @@ const variables = defineModel<AutomationVariable[]>('variables', { default: () =
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="test" class="min-h-0 overflow-y-auto">
|
||||
<TestRunPanel :automation-id="automationId" :before-run="beforeRun" />
|
||||
<TestRunPanel :automation-id="automationId" :before-run="beforeRun" :config-issue="configIssue" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface Run {
|
|||
is_dry_run: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{ automationId: string; beforeRun?: () => Promise<boolean> | boolean }>();
|
||||
const props = defineProps<{ automationId: string; beforeRun?: () => Promise<boolean> | boolean; configIssue?: string | null }>();
|
||||
|
||||
const isStarting = ref(false);
|
||||
const realData = ref(false);
|
||||
|
|
@ -88,20 +88,22 @@ const start = async () => {
|
|||
body: JSON.stringify({ with_real_data: realData.value }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('start failed');
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.message ?? '');
|
||||
}
|
||||
const { run_id: runId } = await response.json();
|
||||
activeRunId.value = runId;
|
||||
await fetchRun(runId);
|
||||
} catch {
|
||||
toast.error(trans('automations.test.error_starting'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message ? error.message : trans('automations.test.error_starting');
|
||||
toast.error(message);
|
||||
} finally {
|
||||
isStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
if (isStarting.value) return;
|
||||
if (isStarting.value || props.configIssue) return;
|
||||
const proceed = await (props.beforeRun?.() ?? true);
|
||||
if (proceed === false) return;
|
||||
await start();
|
||||
|
|
@ -154,13 +156,18 @@ const isZeroFetchResult = (nodeRun: NodeRun): boolean => {
|
|||
<Checkbox v-model="realData" :disabled="isStarting" />
|
||||
{{ $t('automations.test.with_real_data') }}
|
||||
</label>
|
||||
<Button size="sm" :disabled="isStarting" @click="runTest">
|
||||
<Button size="sm" :disabled="isStarting || !!configIssue" @click="runTest">
|
||||
<IconLoader2 v-if="isStarting" class="size-4 animate-spin" />
|
||||
<IconPlayerPlay v-else class="size-4" />
|
||||
{{ $t('automations.test.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="configIssue" class="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-500">
|
||||
<IconAlertCircle class="size-4 flex-shrink-0" />
|
||||
{{ configIssue }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="run === null && !isStarting"
|
||||
class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60"
|
||||
|
|
|
|||
44
resources/js/components/automations/config-validation.ts
Normal file
44
resources/js/components/automations/config-validation.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import { NodeType } from '@/types/automation/node-type';
|
||||
|
||||
interface WorkflowNode {
|
||||
type?: string;
|
||||
data?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the backend WebhookNodeValidator: a webhook payload template must be
|
||||
* valid JSON because the runtime parses it before resolving `{{ }}` placeholders.
|
||||
* An empty or literal-`null` template means "no body" and is valid.
|
||||
*/
|
||||
export const isPayloadTemplateValid = (template: string): boolean => {
|
||||
const trimmed = template.trim();
|
||||
|
||||
if (trimmed === '' || trimmed === 'null') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* First node-config issue that would block a run, or null when every node is
|
||||
* runnable. The frontend gate intentionally covers only the Webhook node — the
|
||||
* Generate node has its own inline compliance UI, and the backend
|
||||
* AutomationConfigValidator remains the safety net for everything.
|
||||
*/
|
||||
export const firstConfigIssue = (nodes: WorkflowNode[]): string | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === NodeType.Webhook && !isPayloadTemplateValid(String(node.data?.payload_template ?? ''))) {
|
||||
return trans('automations.errors.webhook_invalid_payload_json');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { isPayloadTemplateValid } from '@/components/automations/config-validation';
|
||||
import CodeEditor from '@/components/CodeEditor.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -38,18 +39,7 @@ const local = ref<WebhookConfig>({
|
|||
|
||||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
||||
const isPayloadJsonInvalid = computed(() => {
|
||||
const value = local.value.payload_template.trim();
|
||||
if (value === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const isPayloadJsonInvalid = computed(() => !isPayloadTemplateValid(local.value.payload_template));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNo
|
|||
import PublishNodeConfig from '@/components/automations/config/PublishNodeConfig.vue';
|
||||
import TriggerNodeConfig from '@/components/automations/config/TriggerNodeConfig.vue';
|
||||
import WebhookNodeConfig from '@/components/automations/config/WebhookNodeConfig.vue';
|
||||
import { firstConfigIssue } from '@/components/automations/config-validation';
|
||||
import EditorSidebar from '@/components/automations/EditorSidebar.vue';
|
||||
import ConditionNode from '@/components/automations/nodes/ConditionNode.vue';
|
||||
import DelayNode from '@/components/automations/nodes/DelayNode.vue';
|
||||
|
|
@ -109,6 +110,8 @@ const selectedNodeId = ref<string | null>(null);
|
|||
const selectedEdgeId = ref<string | null>(null);
|
||||
const variables = ref<AutomationVariable[]>(props.automation.variables ?? []);
|
||||
|
||||
const configIssue = computed(() => firstConfigIssue(nodes.value));
|
||||
|
||||
watch(
|
||||
() => [props.automation.nodes, props.automation.connections] as const,
|
||||
([newNodes, newEdges]) => {
|
||||
|
|
@ -512,6 +515,7 @@ const defaultEdgeOptions = {
|
|||
v-model:variables="variables"
|
||||
:automation-id="automation.id"
|
||||
:before-run="save"
|
||||
:config-issue="configIssue"
|
||||
:editing="!!selectedNode"
|
||||
:node-title="selectedNode ? $t(`automations.nodes.${selectedNode.type}`) : ''"
|
||||
:deletable="selectedNode?.type !== NodeType.Trigger"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { Form, router } from '@inertiajs/vue3';
|
||||
import { IconTrash } from '@tabler/icons-vue';
|
||||
import { IconAlertCircle, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import AutomationDetailLayout from '@/components/automations/AutomationDetailLayout.vue';
|
||||
import { firstConfigIssue } from '@/components/automations/config-validation';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
|
|
@ -41,6 +42,11 @@ const statusDot = computed(
|
|||
const isActive = computed(() => props.automation.status === 'active');
|
||||
const isToggling = ref(false);
|
||||
|
||||
// A misconfigured node can't be activated (the backend rejects it too); surface
|
||||
// it up front and block the toggle. Pausing an active automation stays allowed.
|
||||
const configIssue = computed(() => firstConfigIssue(props.automation.nodes ?? []));
|
||||
const activationBlocked = computed(() => !isActive.value && configIssue.value !== null);
|
||||
|
||||
const statusDetail = computed(() => {
|
||||
const automation = props.automation;
|
||||
|
||||
|
|
@ -56,7 +62,7 @@ const statusDetail = computed(() => {
|
|||
});
|
||||
|
||||
const toggleActive = () => {
|
||||
if (isToggling.value) return;
|
||||
if (isToggling.value || activationBlocked.value) return;
|
||||
isToggling.value = true;
|
||||
const url = isActive.value
|
||||
? pauseAutomation.url(props.automation.id)
|
||||
|
|
@ -159,7 +165,7 @@ const openDeleteModal = () => {
|
|||
</div>
|
||||
<Switch
|
||||
:model-value="isActive"
|
||||
:disabled="isToggling"
|
||||
:disabled="isToggling || activationBlocked"
|
||||
:aria-label="
|
||||
isActive
|
||||
? $t('automations.actions.pause')
|
||||
|
|
@ -169,6 +175,15 @@ const openDeleteModal = () => {
|
|||
@update:model-value="toggleActive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="activationBlocked"
|
||||
class="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-500"
|
||||
dusk="automation-activation-blocked"
|
||||
>
|
||||
<IconAlertCircle class="size-4 flex-shrink-0" />
|
||||
{{ configIssue }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
|
|
|||
110
tests/Feature/Automation/WebhookNodeValidationTest.php
Normal file
110
tests/Feature/Automation/WebhookNodeValidationTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'current_workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
||||
it('rejects saving a webhook node whose payload template is not valid JSON', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->putJson(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [
|
||||
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
||||
['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
||||
'url' => 'https://example.test/hook',
|
||||
'method' => 'POST',
|
||||
'payload_template' => '{"title": {{fetched.title}}}',
|
||||
]],
|
||||
],
|
||||
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['nodes.1.data.payload_template']);
|
||||
});
|
||||
|
||||
it('allows saving a webhook node whose placeholders are quoted valid JSON', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [
|
||||
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
||||
['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
||||
'url' => 'https://example.test/hook',
|
||||
'method' => 'POST',
|
||||
'payload_template' => '{"title": "{{fetched.title}}"}',
|
||||
]],
|
||||
],
|
||||
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
expect($automation->fresh()->nodes)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('allows saving a webhook node with an empty payload template', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.automations.update', $automation->id), [
|
||||
'nodes' => [
|
||||
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
||||
['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
||||
'url' => 'https://example.test/hook',
|
||||
'method' => 'POST',
|
||||
'payload_template' => '',
|
||||
]],
|
||||
],
|
||||
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
it('refuses to activate an automation whose webhook payload is invalid JSON', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create();
|
||||
$automation->update([
|
||||
'nodes' => array_merge($automation->nodes, [
|
||||
['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => [
|
||||
'url' => 'https://example.test/hook',
|
||||
'payload_template' => '{"title": {{fetched.title}}}',
|
||||
]],
|
||||
]),
|
||||
'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.activate', $automation->id))
|
||||
->assertStatus(422);
|
||||
|
||||
expect($automation->fresh()->status->value)->not->toBe('active');
|
||||
});
|
||||
|
||||
it('refuses to run a test when the webhook payload is invalid JSON', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create();
|
||||
$automation->update([
|
||||
'nodes' => array_merge($automation->nodes, [
|
||||
['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => [
|
||||
'url' => 'https://example.test/hook',
|
||||
'payload_template' => '{"title": {{fetched.title}}}',
|
||||
]],
|
||||
]),
|
||||
'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.automations.test', $automation->id), [])
|
||||
->assertStatus(422);
|
||||
});
|
||||
56
tests/Unit/Automation/AutomationConfigValidatorTest.php
Normal file
56
tests/Unit/Automation/AutomationConfigValidatorTest.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use App\Services\Automation\WebhookNodeValidator;
|
||||
|
||||
it('treats empty and literal-null webhook payloads as valid', function (string $template) {
|
||||
$issue = app(WebhookNodeValidator::class)->issueFor(['payload_template' => $template]);
|
||||
|
||||
expect($issue)->toBeNull();
|
||||
})->with(['', ' ', 'null']);
|
||||
|
||||
it('accepts a webhook payload whose placeholders are quoted valid JSON', function () {
|
||||
$issue = app(WebhookNodeValidator::class)->issueFor([
|
||||
'payload_template' => '{"title": "{{fetched.title}}"}',
|
||||
]);
|
||||
|
||||
expect($issue)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a webhook payload that is not valid JSON', function () {
|
||||
$issue = app(WebhookNodeValidator::class)->issueFor([
|
||||
'payload_template' => '{"title": {{fetched.title}}}',
|
||||
]);
|
||||
|
||||
expect($issue)->toBe(__('automations.errors.webhook_invalid_payload_json'));
|
||||
});
|
||||
|
||||
it('reports an issue per invalid node, keyed to its field and index', function () {
|
||||
$nodes = [
|
||||
['type' => 'trigger', 'data' => ['trigger_type' => 'schedule']],
|
||||
['type' => 'webhook', 'data' => ['payload_template' => 'not json']],
|
||||
['type' => 'webhook', 'data' => ['payload_template' => '{"ok": "{{x}}"}']],
|
||||
['type' => 'webhook', 'data' => ['payload_template' => '{bad']],
|
||||
];
|
||||
|
||||
$issues = app(AutomationConfigValidator::class)->issues($nodes);
|
||||
|
||||
expect($issues)->toHaveCount(2)
|
||||
->and($issues[0])->toMatchArray(['node_index' => 1, 'field' => 'payload_template'])
|
||||
->and($issues[1]['node_index'])->toBe(3);
|
||||
});
|
||||
|
||||
it('returns the first issue message and null when every node is valid', function () {
|
||||
$validator = app(AutomationConfigValidator::class);
|
||||
|
||||
$invalid = [
|
||||
['type' => 'webhook', 'data' => ['payload_template' => 'nope']],
|
||||
['type' => 'webhook', 'data' => ['payload_template' => 'also nope']],
|
||||
];
|
||||
$valid = [['type' => 'webhook', 'data' => ['payload_template' => '{}']]];
|
||||
|
||||
expect($validator->firstMessage($invalid))->toBe(__('automations.errors.webhook_invalid_payload_json'))
|
||||
->and($validator->firstMessage($valid))->toBeNull();
|
||||
});
|
||||
Loading…
Reference in a new issue