From 981df2ee45968bae54fac56874c226fdb94d62b6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 28 Jan 2026 19:42:28 +0100 Subject: [PATCH] Add support API validation rules --- .../Api/Support/SupportResourceController.php | 74 ++++++++++++++++++- .../SupportDataExportResourceRequest.php | 32 ++++++++ ...upportPhotoboothSettingResourceRequest.php | 34 +++++++++ .../Resources/SupportResourceFormRequest.php | 38 ++++++++++ .../SupportTenantFeedbackResourceRequest.php | 29 ++++++++ .../SupportTenantResourceRequest.php | 40 ++++++++++ .../Resources/SupportUserResourceRequest.php | 47 ++++++++++++ app/Support/SupportApiRegistry.php | 13 ++++ config/support-api.php | 20 +++++ tests/Feature/Support/SupportApiTest.php | 48 ++++++++++++ 10 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 app/Http/Requests/Support/Resources/SupportDataExportResourceRequest.php create mode 100644 app/Http/Requests/Support/Resources/SupportPhotoboothSettingResourceRequest.php create mode 100644 app/Http/Requests/Support/Resources/SupportResourceFormRequest.php create mode 100644 app/Http/Requests/Support/Resources/SupportTenantFeedbackResourceRequest.php create mode 100644 app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php create mode 100644 app/Http/Requests/Support/Resources/SupportUserResourceRequest.php diff --git a/app/Http/Controllers/Api/Support/SupportResourceController.php b/app/Http/Controllers/Api/Support/SupportResourceController.php index 46b9c3f..16d478e 100644 --- a/app/Http/Controllers/Api/Support/SupportResourceController.php +++ b/app/Http/Controllers/Api/Support/SupportResourceController.php @@ -2,8 +2,12 @@ namespace App\Http\Controllers\Api\Support; +use App\Enums\DataExportScope; use App\Http\Controllers\Controller; +use App\Http\Requests\Support\Resources\SupportResourceFormRequest; use App\Http\Requests\Support\SupportResourceRequest; +use App\Jobs\GenerateDataExport; +use App\Models\DataExport; use App\Support\ApiError; use App\Support\SupportApiAuthorizer; use App\Support\SupportApiRegistry; @@ -13,6 +17,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Validator; class SupportResourceController extends Controller { @@ -89,14 +94,26 @@ class SupportResourceController extends Controller /** @var Model $model */ $model = new $modelClass; - $payload = $this->filteredPayload($request, $model); + $payload = $this->validatedPayload($request, $resource, 'create', $model); + + if ($payload instanceof JsonResponse) { + return $payload; + } if ($payload === []) { return $this->emptyPayloadResponse($resource); } + if ($resource === 'data-exports') { + $payload = $this->normalizeDataExportPayload($request, $payload); + } + $record = $modelClass::query()->create($payload); + if ($resource === 'data-exports') { + GenerateDataExport::dispatch($record->id); + } + return response()->json([ 'data' => $record, ], 201); @@ -118,7 +135,11 @@ class SupportResourceController extends Controller return $this->resourceNotFoundResponse($resource, $record); } - $payload = $this->filteredPayload($request, $model); + $payload = $this->validatedPayload($request, $resource, 'update', $model); + + if ($payload instanceof JsonResponse) { + return $payload; + } if ($payload === []) { return $this->emptyPayloadResponse($resource); @@ -174,7 +195,7 @@ class SupportResourceController extends Controller return $query->where($keyName, $record)->first(); } - private function filteredPayload(SupportResourceRequest $request, Model $model): array + private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse { $payload = $request->validated('data'); @@ -182,6 +203,28 @@ class SupportResourceController extends Controller return []; } + $validationClass = SupportApiRegistry::validationClass($resource, $action); + + if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) { + $allowedFields = $validationClass::allowedFields($action); + + if ($allowedFields !== []) { + $unexpected = array_diff(array_keys($payload), $allowedFields); + if ($unexpected !== []) { + return $this->invalidFieldResponse($resource, $unexpected); + } + } + + $rules = $validationClass::rulesFor($action, $model); + if ($rules !== []) { + $payload = Validator::make($payload, $rules)->validate(); + } + + if ($allowedFields !== []) { + $payload = Arr::only($payload, $allowedFields); + } + } + $fillable = $model->getFillable(); if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) { @@ -292,6 +335,19 @@ class SupportResourceController extends Controller ); } + private function invalidFieldResponse(string $resource, array $fields): JsonResponse + { + return ApiError::response( + 'support_invalid_fields', + 'Invalid Fields', + "Unsupported fields provided for {$resource}.", + 422, + [ + 'fields' => array_values($fields), + ] + ); + } + private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse { $message = $record @@ -305,4 +361,16 @@ class SupportResourceController extends Controller 404 ); } + + private function normalizeDataExportPayload(Request $request, array $payload): array + { + $payload['user_id'] = $request->user()?->id; + $payload['status'] = DataExport::STATUS_PENDING; + + if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) { + $payload['event_id'] = null; + } + + return $payload; + } } diff --git a/app/Http/Requests/Support/Resources/SupportDataExportResourceRequest.php b/app/Http/Requests/Support/Resources/SupportDataExportResourceRequest.php new file mode 100644 index 0000000..3bfd6c6 --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportDataExportResourceRequest.php @@ -0,0 +1,32 @@ + $scope->value, DataExportScope::cases()); + + return [ + 'scope' => ['required', 'string', Rule::in($scopeValues)], + 'tenant_id' => ['required', 'integer', 'exists:tenants,id'], + 'event_id' => ['nullable', 'integer', 'exists:events,id', 'required_if:scope,event'], + 'include_media' => ['sometimes', 'boolean'], + ]; + } + + public static function allowedFields(string $action): array + { + return [ + 'scope', + 'tenant_id', + 'event_id', + 'include_media', + ]; + } +} diff --git a/app/Http/Requests/Support/Resources/SupportPhotoboothSettingResourceRequest.php b/app/Http/Requests/Support/Resources/SupportPhotoboothSettingResourceRequest.php new file mode 100644 index 0000000..11321d5 --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportPhotoboothSettingResourceRequest.php @@ -0,0 +1,34 @@ + ['sometimes', 'integer', 'min:1', 'max:65535'], + 'rate_limit_per_minute' => ['sometimes', 'integer', 'min:1', 'max:200'], + 'expiry_grace_days' => ['sometimes', 'integer', 'min:0', 'max:14'], + 'require_ftps' => ['sometimes', 'boolean'], + 'allowed_ip_ranges' => ['sometimes', 'array'], + 'control_service_base_url' => ['sometimes', 'nullable', 'string', 'max:191'], + 'control_service_token_identifier' => ['sometimes', 'nullable', 'string', 'max:191'], + ]; + } + + public static function allowedFields(string $action): array + { + return [ + 'ftp_port', + 'rate_limit_per_minute', + 'expiry_grace_days', + 'require_ftps', + 'allowed_ip_ranges', + 'control_service_base_url', + 'control_service_token_identifier', + ]; + } +} diff --git a/app/Http/Requests/Support/Resources/SupportResourceFormRequest.php b/app/Http/Requests/Support/Resources/SupportResourceFormRequest.php new file mode 100644 index 0000000..195687e --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportResourceFormRequest.php @@ -0,0 +1,38 @@ + + */ + public static function rulesFor(string $action, ?Model $model = null): array + { + return []; + } + + /** + * @return array + */ + public static function allowedFields(string $action): array + { + return []; + } + + /** + * @return array + */ + public function rules(): array + { + return static::rulesFor('update', null); + } +} diff --git a/app/Http/Requests/Support/Resources/SupportTenantFeedbackResourceRequest.php b/app/Http/Requests/Support/Resources/SupportTenantFeedbackResourceRequest.php new file mode 100644 index 0000000..fd9afe7 --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportTenantFeedbackResourceRequest.php @@ -0,0 +1,29 @@ + [ + 'sometimes', + 'string', + Rule::in(['pending', 'resolved', 'hidden', 'deleted']), + ], + 'moderation_notes' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } + + public static function allowedFields(string $action): array + { + return [ + 'status', + 'moderation_notes', + ]; + } +} diff --git a/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php b/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php new file mode 100644 index 0000000..0a0f659 --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportTenantResourceRequest.php @@ -0,0 +1,40 @@ +getKey(); + + return [ + 'slug' => [ + 'sometimes', + 'string', + 'max:255', + Rule::unique('tenants', 'slug')->ignore($tenantId), + ], + 'contact_email' => ['sometimes', 'email', 'max:255'], + 'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'], + 'is_active' => ['sometimes', 'boolean'], + 'is_suspended' => ['sometimes', 'boolean'], + 'features' => ['sometimes', 'array'], + ]; + } + + public static function allowedFields(string $action): array + { + return [ + 'slug', + 'contact_email', + 'paddle_customer_id', + 'is_active', + 'is_suspended', + 'features', + ]; + } +} diff --git a/app/Http/Requests/Support/Resources/SupportUserResourceRequest.php b/app/Http/Requests/Support/Resources/SupportUserResourceRequest.php new file mode 100644 index 0000000..ecac1f0 --- /dev/null +++ b/app/Http/Requests/Support/Resources/SupportUserResourceRequest.php @@ -0,0 +1,47 @@ +getKey(); + + return [ + 'first_name' => ['sometimes', 'string', 'max:255'], + 'last_name' => ['sometimes', 'string', 'max:255'], + 'username' => [ + 'sometimes', + 'string', + 'max:255', + Rule::unique('users', 'username')->ignore($userId), + ], + 'email' => [ + 'sometimes', + 'email', + 'max:255', + Rule::unique('users', 'email')->ignore($userId), + ], + 'address' => ['sometimes', 'string', 'max:1000'], + 'phone' => ['sometimes', 'string', 'max:50'], + 'preferred_locale' => ['sometimes', 'string', 'max:10'], + ]; + } + + public static function allowedFields(string $action): array + { + return [ + 'first_name', + 'last_name', + 'username', + 'email', + 'address', + 'phone', + 'preferred_locale', + ]; + } +} diff --git a/app/Support/SupportApiRegistry.php b/app/Support/SupportApiRegistry.php index fdaa71a..c7fe810 100644 --- a/app/Support/SupportApiRegistry.php +++ b/app/Support/SupportApiRegistry.php @@ -22,6 +22,19 @@ class SupportApiRegistry return $resources[$resource] ?? null; } + public static function validationClass(string $resource, string $action): ?string + { + $config = self::get($resource); + + if (! $config) { + return null; + } + + $validation = $config['validation'][$action] ?? null; + + return is_string($validation) ? $validation : null; + } + /** * @return array */ diff --git a/config/support-api.php b/config/support-api.php index 6bfb2ea..3a42735 100644 --- a/config/support-api.php +++ b/config/support-api.php @@ -1,5 +1,10 @@ ['support:write'], 'actions' => ['support:actions'], ], + 'validation' => [ + 'update' => SupportTenantResourceRequest::class, + ], 'mutations' => [ 'create' => false, 'update' => true, @@ -70,6 +78,9 @@ return [ 'read' => ['support:read'], 'write' => ['support:write'], ], + 'validation' => [ + 'update' => SupportUserResourceRequest::class, + ], 'mutations' => [ 'create' => false, 'update' => true, @@ -201,6 +212,9 @@ return [ 'read' => ['support:read'], 'write' => ['support:write'], ], + 'validation' => [ + 'update' => SupportTenantFeedbackResourceRequest::class, + ], 'mutations' => [ 'create' => false, 'update' => true, @@ -253,6 +267,9 @@ return [ 'read' => ['support:ops'], 'write' => ['support:ops'], ], + 'validation' => [ + 'create' => SupportDataExportResourceRequest::class, + ], 'mutations' => [ 'create' => true, 'update' => false, @@ -266,6 +283,9 @@ return [ 'read' => ['support:ops'], 'write' => ['support:ops'], ], + 'validation' => [ + 'update' => SupportPhotoboothSettingResourceRequest::class, + ], 'mutations' => [ 'create' => false, 'update' => true, diff --git a/tests/Feature/Support/SupportApiTest.php b/tests/Feature/Support/SupportApiTest.php index 79886ea..f47fa34 100644 --- a/tests/Feature/Support/SupportApiTest.php +++ b/tests/Feature/Support/SupportApiTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Support; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -34,4 +35,51 @@ class SupportApiTest extends TestCase $response->assertOk() ->assertJsonStructure(['data', 'meta']); } + + public function test_support_resource_update_rejects_invalid_fields(): void + { + $user = User::factory()->create([ + 'role' => 'super_admin', + ]); + + $tenant = Tenant::factory()->create(); + + Sanctum::actingAs($user, ['support-admin', 'support:write']); + + $response = $this->patchJson('/api/v1/support/tenants/'.$tenant->id, [ + 'data' => [ + 'name' => 'Unauthorized', + ], + ]); + + $response->assertStatus(422) + ->assertJsonPath('error.code', 'support_invalid_fields'); + } + + public function test_support_data_export_create_sets_user_and_dispatches_job(): void + { + $user = User::factory()->create([ + 'role' => 'super_admin', + ]); + + $tenant = Tenant::factory()->create(); + + Bus::fake(); + Sanctum::actingAs($user, ['support-admin', 'support:ops']); + + $response = $this->postJson('/api/v1/support/data-exports', [ + 'data' => [ + 'scope' => 'tenant', + 'tenant_id' => $tenant->id, + 'include_media' => true, + ], + ]); + + $response->assertCreated() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.user_id', $user->id) + ->assertJsonPath('data.event_id', null); + + Bus::assertDispatched(\App\Jobs\GenerateDataExport::class); + } }