Add validation for contribution generator
continuous-integration/drone/push Build is failing Details

This commit is contained in:
philipp lang 2023-03-14 22:29:39 +01:00
parent 1ba60a455f
commit 6960b0295a
13 changed files with 396 additions and 59 deletions

View File

@ -0,0 +1,53 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\ContributionFactory;
use App\Rules\JsonBase64Rule;
use Illuminate\Support\Facades\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\Compiler;
use Zoomyboy\Tex\Tex;
class GenerateAction
{
use AsAction;
/**
* @param class-string<DvDocument> $document
* @param array<string, mixed> $payload
*/
public function handle(string $document, array $payload): Compiler
{
return Tex::compile($document::fromRequest($payload));
}
public function asController(ActionRequest $request): Compiler
{
$payload = $this->payload($request);
$type = data_get($payload, 'type');
ValidateAction::validateType($type);
Validator::make($payload, app(ContributionFactory::class)->rules($type))->validate();
return $this->handle($type, $payload);
}
/**
* @return array<stirng, mixed>
*/
public function rules(): array
{
return [
'payload' => [new JsonBase64Rule()],
];
}
/**
* @return array<string, string>
*/
private function payload(ActionRequest $request): array
{
return json_decode(base64_decode($request->input('payload', '')), true);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\ContributionFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class ValidateAction
{
use AsAction;
public function asController(): JsonResponse
{
return response()->json(['valid' => true]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return app(ContributionFactory::class)->rules(request()->type);
}
public function prepareForValidation(ActionRequest $request): void
{
static::validateType($request->input('type'));
}
public static function validateType(?string $type = null): void
{
Validator::make(['type' => $type], app(ContributionFactory::class)->typeRule())->validate();
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Contribution;
use App\Contribution\Documents\SolingenDocument;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class ContributionController extends Controller
{
public function generate(Request $request): BaseCompiler
{
$payload = json_decode(base64_decode($request->input('payload', '')), true);
$validated = Validator::make($payload, [
'dateFrom' => 'required|date|date_format:Y-m-d',
'dateUntil' => 'required|date|date_format:Y-m-d',
'country' => 'required|exists:countries,id',
'eventName' => 'required|max:100',
'members' => 'array',
'members.*' => 'integer|exists:members,id',
'type' => 'required|string',
'zipLocation' => 'required|string',
])->validate();
/** @var class-string<SolingenDocument> */
$type = $validated['type'];
return Tex::compile($type::fromRequest($validated));
}
}

View File

@ -6,6 +6,8 @@ use App\Contribution\Documents\ContributionDocument;
use App\Contribution\Documents\DvDocument;
use App\Contribution\Documents\RemscheidDocument;
use App\Contribution\Documents\SolingenDocument;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
class ContributionFactory
{
@ -19,13 +21,36 @@ class ContributionFactory
];
/**
* @return array<int, array{id: string, name: string}>
* @return Collection<int, array{title: mixed, class: mixed}>
*/
public function compilerSelect(): array
public function compilerSelect(): Collection
{
return collect($this->documents)->map(fn ($document) => [
'title' => $document::getName(),
'class' => $document,
])->toArray();
]);
}
/**
* @return array<string, mixed>
*/
public function typeRule(): array
{
return [
'type' => ['required', Rule::in($this->documents)],
];
}
/**
* @param class-string<ContributionDocument> $type
*
* @return array<string, mixed>
*/
public function rules(string $type): array
{
return [
...$type::globalRules(),
...$type::rules(),
];
}
}

View File

@ -7,4 +7,20 @@ use Zoomyboy\Tex\Document;
abstract class ContributionDocument extends Document
{
abstract public static function getName(): string;
/**
* @return array<string, mixed>
*/
abstract public static function rules(): array;
/**
* @return array<string, mixed>
*/
public static function globalRules(): array
{
return [
'members' => 'present|array|min:1',
'members.*' => 'integer|exists:members,id',
];
}
}

View File

@ -111,4 +111,18 @@ class DvDocument extends ContributionDocument
{
return 'Für DV erstellen';
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'country' => 'required|integer|exists:countries,id',
'zipLocation' => 'required|string',
'eventName' => 'required|string',
];
}
}

View File

@ -85,4 +85,17 @@ class RemscheidDocument extends ContributionDocument
{
return 'Für Remscheid erstellen';
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'zipLocation' => 'required|string',
'country' => 'required|integer|exists:countries,id',
];
}
}

View File

@ -95,4 +95,17 @@ class SolingenDocument extends ContributionDocument
{
return 'Für Stadt Solingen erstellen';
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'zipLocation' => 'required|string',
'eventName' => 'required|string',
];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class JsonBase64Rule implements Rule
{
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct()
{
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
*
* @return bool
*/
public function passes($attribute, $value)
{
if (!is_string($value)) {
return false;
}
if (false === base64_decode($value, true)) {
return false;
}
if (base64_encode(base64_decode($value, true)) !== $value) {
return false;
}
if (!is_array(json_decode(base64_decode($value), true))) {
return false;
}
return true;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'The validation error message.';
}
}

View File

@ -88,9 +88,14 @@ export default {
},
},
methods: {
submit() {
var payload = btoa(JSON.stringify(this.values));
window.open(`/contribution/generate?payload=${payload}`);
async submit() {
try {
await this.axios.post('/contribution-validate', this.values);
var payload = btoa(JSON.stringify(this.values));
window.open(`/contribution-generate?payload=${payload}`);
} catch (e) {
this.errorsFromException(e);
}
},
onSubmitMemberResult(selected) {
if (this.values.members.find((m) => m === selected.id) !== undefined) {

View File

@ -10,7 +10,8 @@ use App\Activity\Api\SubactivityShowAction;
use App\Activity\Api\SubactivityStoreAction;
use App\Activity\Api\SubactivityUpdateAction;
use App\Contribution\Actions\FormAction as ContributionFormAction;
use App\Contribution\ContributionController;
use App\Contribution\Actions\GenerateAction as ContributionGenerateAction;
use App\Contribution\Actions\ValidateAction as ContributionValidateAction;
use App\Course\Controllers\CourseController;
use App\Efz\ShowEfzDocumentAction;
use App\Home\Actions\IndexAction as HomeIndexAction;
@ -55,8 +56,6 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::resource('member.course', CourseController::class);
Route::get('/member/{member}/efz', ShowEfzDocumentAction::class)->name('efz');
Route::get('/member/{member}/resync', MemberResyncAction::class)->name('member.resync');
Route::get('/contribution', ContributionFormAction::class)->name('contribution.form');
Route::get('/contribution/generate', [ContributionController::class, 'generate'])->name('contribution.generate');
Route::get('/activity', ActivityIndexAction::class)->name('activity.index');
Route::get('/activity/{activity}/edit', ActivityEditAction::class)->name('activity.edit');
Route::get('/activity/create', ActivityCreateAction::class)->name('activity.create');
@ -66,4 +65,9 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::post('/subactivity', SubactivityStoreAction::class)->name('api.subactivity.store');
Route::patch('/subactivity/{subactivity}', SubactivityUpdateAction::class)->name('api.subactivity.update');
Route::get('/subactivity/{subactivity}', SubactivityShowAction::class)->name('api.subactivity.show');
// ------------------------------- Contributions -------------------------------
Route::get('/contribution', ContributionFormAction::class)->name('contribution.form');
Route::get('/contribution-generate', ContributionGenerateAction::class)->name('contribution.generate');
Route::post('/contribution-validate', ContributionValidateAction::class)->name('contribution.validate');
});

View File

@ -2,10 +2,14 @@
namespace Tests\Feature\Contribution;
use App\Contribution\Documents\ContributionDocument;
use App\Contribution\Documents\DvDocument;
use App\Contribution\Documents\SolingenDocument;
use App\Country;
use App\Member\Member;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\ContributionRequestFactory;
use Tests\TestCase;
use Zoomyboy\Tex\Tex;
@ -29,7 +33,7 @@ class StoreTest extends TestCase
$member1 = Member::factory()->defaults()->create(['address' => 'Maxstr 44', 'zip' => '42719', 'firstname' => 'Max', 'lastname' => 'Muster']);
$member2 = Member::factory()->defaults()->create(['address' => 'Maxstr 44', 'zip' => '42719', 'firstname' => 'Jane', 'lastname' => 'Muster']);
$response = $this->call('GET', '/contribution/generate', [
$response = $this->call('GET', '/contribution-generate', [
'payload' => base64_encode(json_encode([
'country' => $country->id,
'dateFrom' => '1991-06-15',
@ -46,24 +50,115 @@ class StoreTest extends TestCase
Tex::assertCompiled($type, fn ($document) => $document->hasAllContent($bodyChecks));
}
public function testItValidatesInput(): void
/**
* @testWith [""]
* ["aaaa"]
* ["YWFhCg=="]
*/
public function testInputShouldBeBase64EncodedJson(string $payload): void
{
$this->login()->loginNami();
$country = Country::factory()->create();
$member1 = Member::factory()->defaults()->create();
$response = $this->call('GET', '/contribution/generate', [
'payload' => base64_encode(json_encode([
'country' => $country->id,
'dateFrom' => '',
'dateUntil' => '1991-06-16',
'eventName' => 'Super tolles Lager',
'members' => [$member1->id],
'type' => SolingenDocument::class,
'zipLocation' => '42777 SG',
])),
]);
$this->call('GET', '/contribution-generate', ['payload' => $payload])->assertSessionHasErrors('payload');
}
$response->assertSessionHasErrors('dateFrom');
/**
* @param array<string, string> $input
* @param class-string<ContributionDocument> $documentClass
* @dataProvider validationDataProvider
*/
public function testItValidatesInput(array $input, string $documentClass, string $errorField): void
{
$this->login()->loginNami();
Country::factory()->create();
Member::factory()->defaults()->create();
$this->postJson('/contribution-validate', ContributionRequestFactory::new()->type($documentClass)->state($input)->create())
->assertJsonValidationErrors($errorField);
}
/**
* @param array<string, string> $input
* @param class-string<ContributionDocument> $documentClass
* @dataProvider validationDataProvider
*/
public function testItValidatesInputBeforeGeneration(array $input, string $documentClass, string $errorField): void
{
$this->login()->loginNami();
Country::factory()->create();
Member::factory()->defaults()->create();
$this->call('GET', '/contribution-generate', [
'payload' => ContributionRequestFactory::new()->type($documentClass)->state($input)->toBase64(),
])->assertSessionHasErrors($errorField);
}
protected function validationDataProvider(): Generator
{
yield [
['type' => 'aaa'],
SolingenDocument::class,
'type',
];
yield [
['type' => ''],
SolingenDocument::class,
'type',
];
yield [
['dateFrom' => ''],
SolingenDocument::class,
'dateFrom',
];
yield [
['dateFrom' => '2022-01'],
SolingenDocument::class,
'dateFrom',
];
yield [
['dateUntil' => ''],
SolingenDocument::class,
'dateUntil',
];
yield [
['dateUntil' => '2022-01'],
SolingenDocument::class,
'dateUntil',
];
yield [
['country' => -1],
DvDocument::class,
'country',
];
yield [
['country' => 'AAAA'],
DvDocument::class,
'country',
];
yield [
['members' => 'A'],
DvDocument::class,
'members',
];
yield [
['members' => [99999]],
DvDocument::class,
'members.0',
];
yield [
['members' => ['lalala']],
DvDocument::class,
'members.0',
];
yield [
['eventName' => ''],
SolingenDocument::class,
'eventName',
];
yield [
['zipLocation' => ''],
SolingenDocument::class,
'zipLocation',
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Tests\RequestFactories;
use App\Contribution\ContributionFactory;
use App\Contribution\Documents\ContributionDocument;
use App\Country;
use App\Member\Member;
use Worksome\RequestFactories\RequestFactory;
class ContributionRequestFactory extends RequestFactory
{
public function definition(): array
{
$compilers = collect(app(ContributionFactory::class)->compilerSelect())->pluck('class');
return [
'country' => $this->faker->randomElement(Country::get())->id,
'dateFrom' => $this->faker->date(),
'dateUntil' => $this->faker->date(),
'eventName' => $this->faker->words(3, true),
'members' => [$this->faker->randomElement(Member::get())->id],
'type' => $this->faker->randomElement($compilers),
'zipLocation' => $this->faker->city,
];
}
public function toBase64(): string
{
return base64_encode(json_encode($this->create()));
}
/**
* @param class-string<ContributionDocument> $type
*/
public function type(string $type): self
{
return $this->state(['type' => $type]);
}
}