Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.87% covered (danger)
33.87%
42 / 124
25.00% covered (danger)
25.00%
4 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateSectionRemoteConfigRequest
33.87% covered (danger)
33.87%
42 / 124
25.00% covered (danger)
25.00%
4 / 16
205.74
0.00% covered (danger)
0.00%
0 / 1
 rules
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
1.07
 withValidator
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 sectionData
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 linkedinSelectorsRules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outlookDomainsRules
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 timingRules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 featureFlagsRules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 featuresRules
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 playlistsRules
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 blacklistConfigRules
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 uiVisibilityRules
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 fieldInjectionRules
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 forcePlainTextRules
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 displayStrategyRules
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 validateLinkedinSelectorKeys
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 validateRegexPattern
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Http\Requests\v2\RemoteConfig;
4
5use App\Http\Models\RemoteConfig;
6use App\Http\Requests\v2\Parameter\Concerns\AuthorizesVengresoAdmin;
7use Illuminate\Foundation\Http\FormRequest;
8
9/**
10 * Request for partial update (PATCH) of a single config section.
11 *
12 * The request body IS the section data directly (no wrapper key).
13 * Rules are determined dynamically based on the {section} route parameter.
14 *
15 * @property string|null $change_description Optional description of the change
16 */
17class UpdateSectionRemoteConfigRequest extends FormRequest
18{
19    use AuthorizesVengresoAdmin;
20
21    /**
22     * Get the validation rules that apply to the request.
23     *
24     * @return array<string, mixed>
25     */
26    public function rules(): array
27    {
28        $section = $this->route('section');
29
30        $rules = match ($section) {
31            'linkedin_selectors' => $this->linkedinSelectorsRules(),
32            'outlook_domains' => $this->outlookDomainsRules(),
33            'timing' => $this->timingRules(),
34            'feature_flags' => $this->featureFlagsRules(),
35            'features' => $this->featuresRules(),
36            'playlists' => $this->playlistsRules(),
37            'blacklist_config' => $this->blacklistConfigRules(),
38            'ui_visibility' => $this->uiVisibilityRules(),
39            'field_injection' => $this->fieldInjectionRules(),
40            'force_plain_text' => $this->forcePlainTextRules(),
41            'display_strategy_grammar', 'display_strategy_magic_wand', 'display_strategy_paragraph' => $this->displayStrategyRules(),
42            default => [],
43        };
44
45        $rules['change_description'] = ['sometimes', 'nullable', 'string', 'max:500'];
46
47        return $rules;
48    }
49
50    /**
51     * Configure the validator instance.
52     *
53     * @param  \Illuminate\Validation\Validator  $validator
54     */
55    public function withValidator($validator): void
56    {
57        $validator->after(function ($validator) {
58            $section = $this->route('section');
59
60            if ($section === 'linkedin_selectors') {
61                $this->validateLinkedinSelectorKeys($validator);
62            }
63
64            if ($section === 'outlook_domains') {
65                $this->validateRegexPattern($validator);
66            }
67        });
68    }
69
70    /**
71     * Get the section data from the request body (excluding change_description).
72     *
73     * For sections with no field-level rules (e.g. linkedin_selectors),
74     * validated() returns empty since it only includes fields with rules.
75     * In those cases we use the raw input, as validation is handled
76     * by withValidator() custom logic.
77     *
78     * @return array<string, mixed>
79     */
80    public function sectionData(): array
81    {
82        $section = $this->route('section');
83
84        if ($section === 'linkedin_selectors') {
85            return $this->except('change_description');
86        }
87
88        $data = $this->validated();
89        unset($data['change_description']);
90
91        return $data;
92    }
93
94    /**
95     * LinkedIn selectors rules - body IS the selectors object.
96     *
97     * @return array<string, mixed>
98     */
99    private function linkedinSelectorsRules(): array
100    {
101        return [];
102    }
103
104    /**
105     * Outlook domains rules.
106     *
107     * @return array<string, mixed>
108     */
109    private function outlookDomainsRules(): array
110    {
111        return [
112            'hostnames' => ['required', 'array', 'min:1'],
113            'hostnames.*' => ['string'],
114            'regex_pattern' => ['required', 'string'],
115        ];
116    }
117
118    /**
119     * Timing rules.
120     *
121     * @return array<string, mixed>
122     */
123    private function timingRules(): array
124    {
125        return [
126            'default_clear_buffer_timeout' => ['sometimes', 'integer', 'min:100', 'max:10000'],
127            'cleanup_interval' => ['sometimes', 'integer', 'min:5000', 'max:300000'],
128            'cache_ttl' => ['sometimes', 'integer', 'min:1000', 'max:3600000'],
129        ];
130    }
131
132    /**
133     * Feature flags rules.
134     *
135     * @return array<string, mixed>
136     */
137    private function featureFlagsRules(): array
138    {
139        return [
140            'disable_old_linkedin_plugin' => ['sometimes', 'boolean'],
141        ];
142    }
143
144    /**
145     * Features rules.
146     *
147     * @return array<string, mixed>
148     */
149    private function featuresRules(): array
150    {
151        return [
152            '*' => ['boolean'],
153        ];
154    }
155
156    /**
157     * Playlists rules.
158     *
159     * @return array<string, mixed>
160     */
161    private function playlistsRules(): array
162    {
163        return [
164            '*' => ['array'],
165            '*.title' => ['required', 'string'],
166            '*.link' => ['sometimes', 'string'],
167            '*.description' => ['sometimes', 'string'],
168        ];
169    }
170
171    /**
172     * Blacklist config rules.
173     *
174     * @return array<string, mixed>
175     */
176    private function blacklistConfigRules(): array
177    {
178        return [
179            'ignored_input_types' => ['sometimes', 'array'],
180            'ignored_input_types.*' => ['string'],
181            'classes' => ['sometimes', 'array'],
182            'classes.*' => ['string'],
183            'ids' => ['sometimes', 'array'],
184            'ids.*' => ['string'],
185            'attributes' => ['sometimes', 'array'],
186            'attributes.*' => ['string'],
187            'domain_rules' => ['sometimes', 'array'],
188        ];
189    }
190
191    /**
192     * UI visibility rules.
193     *
194     * @return array<string, mixed>
195     */
196    private function uiVisibilityRules(): array
197    {
198        return [
199            'show_rewrite' => ['sometimes', 'boolean'],
200            'show_write_upgrade_button' => ['sometimes', 'boolean'],
201            'show_upgrade_button' => ['sometimes', 'boolean'],
202            'show_write_message' => ['sometimes', 'string'],
203        ];
204    }
205
206    /**
207     * Field injection rules.
208     *
209     * @return array<string, mixed>
210     */
211    private function fieldInjectionRules(): array
212    {
213        return [
214            '*.domain' => ['required', 'string'],
215            '*.fields' => ['required', 'array', 'min:1'],
216            '*.fields.*' => ['array'],
217            '*.fields.*.selector' => ['required', 'string'],
218            '*.fields.*.is_inside_shadow_dom' => ['sometimes', 'boolean'],
219            '*.fields.*.inside_of' => ['sometimes', 'string'],
220            '*.fields.*.not_inside_of' => ['sometimes', 'string'],
221            '*.fields.*.has_parent_selector' => ['sometimes', 'string'],
222            '*.fields.*.icon_offset_bottom' => ['sometimes', 'integer'],
223            '*.fields.*.icon_offset_right' => ['sometimes', 'integer'],
224            '*.fields.*.icon_height' => ['sometimes', 'integer'],
225            '*.fields.*.icon_width' => ['sometimes', 'integer'],
226        ];
227    }
228
229    /**
230     * Force plain text rules.
231     *
232     * Array of domain entries. Each entry has a required domain and optional fields array.
233     * When fields is absent or empty, all fields on that domain are forced to plain text.
234     * The wildcard domain "*" applies globally across all sites.
235     *
236     * @return array<string, mixed>
237     */
238    private function forcePlainTextRules(): array
239    {
240        return [
241            '*.domain' => ['required', 'string'],
242            '*.fields' => ['sometimes', 'array'],
243            '*.fields.*' => ['array'],
244            '*.fields.*.selector' => ['required', 'string'],
245            '*.fields.*.inside_of' => ['sometimes', 'string'],
246            '*.fields.*.not_inside_of' => ['sometimes', 'string'],
247        ];
248    }
249
250    /**
251     * Display strategy rules for Grammar, Magic Wand, and Paragraph features.
252     *
253     * Array of per-domain rules. Each entry has a required domain and a required fields
254     * array. Each field specifies a CSS selector (or "*" catch-all) and a strategy
255     * ('mirror', 'mainPage', or 'dialog'). An optional inside_of or not_inside_of
256     * CSS selector can narrow the match.
257     *
258     * @return array<string, mixed>
259     */
260    private function displayStrategyRules(): array
261    {
262        return [
263            '*.domain' => ['required', 'string'],
264            '*.fields' => ['required', 'array', 'min:1'],
265            '*.fields.*' => ['array'],
266            '*.fields.*.selector' => ['required', 'string'],
267            '*.fields.*.inside_of' => ['sometimes', 'string'],
268            '*.fields.*.not_inside_of' => ['sometimes', 'string'],
269            '*.fields.*.strategy' => ['required', 'string', 'in:mirror,mainPage,dialog'],
270        ];
271    }
272
273    /**
274     * Validate linkedin_selectors has required top-level keys and detection sub-keys.
275     *
276     * @param  \Illuminate\Validation\Validator  $validator
277     */
278    private function validateLinkedinSelectorKeys($validator): void
279    {
280        $data = $this->all();
281        unset($data['change_description']);
282
283        $missingKeys = array_diff(RemoteConfig::LINKEDIN_SELECTOR_KEYS, array_keys($data));
284        if (! empty($missingKeys)) {
285            $validator->errors()->add(
286                'linkedin_selectors',
287                'Missing required keys: '.implode(', ', $missingKeys)
288            );
289        }
290
291        if (isset($data['detection']) && is_array($data['detection'])) {
292            $missingDetection = array_diff(RemoteConfig::LINKEDIN_DETECTION_KEYS, array_keys($data['detection']));
293            if (! empty($missingDetection)) {
294                $validator->errors()->add(
295                    'linkedin_selectors.detection',
296                    'Missing required detection keys: '.implode(', ', $missingDetection)
297                );
298            }
299        }
300    }
301
302    /**
303     * Validate regex_pattern is a valid PCRE pattern.
304     *
305     * @param  \Illuminate\Validation\Validator  $validator
306     */
307    private function validateRegexPattern($validator): void
308    {
309        $pattern = $this->input('regex_pattern');
310
311        if (! is_string($pattern)) {
312            return;
313        }
314
315        if (@preg_match('/'.$pattern.'/', '') === false) {
316            $validator->errors()->add(
317                'regex_pattern',
318                'The regex pattern is not a valid PCRE pattern.'
319            );
320        }
321    }
322}