feat(form-builder): retry/resolve/dismiss API endpoints + dual-route auth (WS-6)
Two route groups: /api/v1/admin/form-failures (super_admin platform) and
/api/v1/organisations/{organisation}/form-failures (org_admin scoped).
Same controller, policy authorises via FK chain (RFC V3). Cross-tenant
access returns 404 not 403 to prevent enumeration.
Resolve takes optional note; Dismiss requires DismissalReasonType
enum with conditional note (mandatory for 'other'). Both via
FormRequest validation with explicit i18n message keys.
Implementation note: Laravel implicit model binding for nested-namespace
ULID models doesn't pick up reliably across nested route groups. Using
manual resolveFailure() helper that loads withoutGlobalScopes() (so
cross-tenant access still reaches the policy, which translates denied →
404 per V3). Policy explicitly checks soft-delete via deleted_at since
withoutGlobalScopes bypasses SoftDeletes too. Policy registered
explicitly in AppServiceProvider — auto-discovery doesn't reliably
resolve App\Models\FormBuilder\* → App\Policies\FormBuilder\*.
NOT: admin UI (session 3). Not: public form routes (no API contract
notification needed).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\FormBuilder;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\ApplyStatus;
|
||||||
|
use App\Enums\FormBuilder\DismissalReasonType;
|
||||||
|
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\FormBuilder\DismissFailureRequest;
|
||||||
|
use App\Http\Requests\FormBuilder\ResolveFailureRequest;
|
||||||
|
use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource;
|
||||||
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
|
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-WS-6 §3 (Q5) + §4 (V3) — admin endpoints for the binding-pipeline
|
||||||
|
* failure workflow. Two route groups in api.php (org-scoped + super_admin
|
||||||
|
* platform). Cross-tenant access returns 404, never 403, to prevent
|
||||||
|
* resource-existence enumeration.
|
||||||
|
*/
|
||||||
|
final class FormSubmissionActionFailureController extends Controller
|
||||||
|
{
|
||||||
|
public function orgIndex(Organisation $organisation): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
|
||||||
|
|
||||||
|
$failures = FormSubmissionActionFailure::query()
|
||||||
|
->whereHas('submission', function ($q) use ($organisation): void {
|
||||||
|
/** @var \Illuminate\Database\Eloquent\Builder<FormSubmission> $q */
|
||||||
|
$q->where('organisation_id', $organisation->id);
|
||||||
|
})
|
||||||
|
->latest('failed_at')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
return FormSubmissionActionFailureResource::collection($failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function platformIndex(): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
Gate::authorize('viewAny', FormSubmissionActionFailure::class);
|
||||||
|
|
||||||
|
$failures = FormSubmissionActionFailure::query()
|
||||||
|
->latest('failed_at')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
return FormSubmissionActionFailureResource::collection($failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(\Illuminate\Http\Request $request): FormSubmissionActionFailureResource
|
||||||
|
{
|
||||||
|
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||||
|
$this->authorizeOrNotFound('view', $failure);
|
||||||
|
|
||||||
|
return new FormSubmissionActionFailureResource($failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retry(\Illuminate\Http\Request $request, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse
|
||||||
|
{
|
||||||
|
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||||
|
$this->authorizeOrNotFound('retry', $failure);
|
||||||
|
|
||||||
|
if (! $failure->canBeRetried()) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'cannot_retry',
|
||||||
|
'message' => 'Failure is dismissed; cannot retry.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$submission = $failure->submission;
|
||||||
|
if ($submission === null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'submission_gone',
|
||||||
|
'message' => 'Parent submission has been deleted.',
|
||||||
|
], 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($applicator, $submission): void {
|
||||||
|
$result = $applicator->apply($submission);
|
||||||
|
FormSubmission::query()
|
||||||
|
->whereKey($submission->id)
|
||||||
|
->update([
|
||||||
|
'apply_status' => $result->applyStatus()->value,
|
||||||
|
'apply_completed_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
$failure->retry_count = (int) $failure->retry_count + 1;
|
||||||
|
$failure->resolved_at = now();
|
||||||
|
$failure->save();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
DB::transaction(function () use ($failure, $submission, $e): void {
|
||||||
|
FormSubmissionActionFailure::query()->create([
|
||||||
|
'form_submission_id' => $submission->id,
|
||||||
|
'listener_class' => $failure->listener_class,
|
||||||
|
'failed_at' => now(),
|
||||||
|
'exception_class' => $e::class,
|
||||||
|
'exception_message' => $e->getMessage(),
|
||||||
|
'context' => ['retry_of' => (string) $failure->id],
|
||||||
|
]);
|
||||||
|
FormSubmissionActionFailure::query()
|
||||||
|
->whereKey($failure->id)
|
||||||
|
->update(['retry_count' => (int) $failure->retry_count + 1]);
|
||||||
|
FormSubmission::query()
|
||||||
|
->whereKey($submission->id)
|
||||||
|
->update(['apply_status' => ApplyStatus::FAILED->value]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FormSubmissionActionFailureResource($failure->refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(
|
||||||
|
ResolveFailureRequest $request,
|
||||||
|
): FormSubmissionActionFailureResource|JsonResponse {
|
||||||
|
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||||
|
$this->authorizeOrNotFound('resolve', $failure);
|
||||||
|
|
||||||
|
if ($failure->resolved_at !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'already_resolved',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failure->dismissed_at !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'already_dismissed',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failure->resolved_at = now();
|
||||||
|
$failure->resolved_note = $request->input('note');
|
||||||
|
$failure->resolved_by_user_id = $request->user()?->id;
|
||||||
|
$failure->save();
|
||||||
|
|
||||||
|
return new FormSubmissionActionFailureResource($failure->refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismiss(
|
||||||
|
DismissFailureRequest $request,
|
||||||
|
): FormSubmissionActionFailureResource|JsonResponse {
|
||||||
|
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||||
|
$this->authorizeOrNotFound('dismiss', $failure);
|
||||||
|
|
||||||
|
if ($failure->resolved_at !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'already_resolved',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failure->dismissed_at !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'already_dismissed',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failure->dismissed_at = now();
|
||||||
|
$failure->dismissed_reason_type = DismissalReasonType::from((string) $request->input('reason_type'));
|
||||||
|
$failure->dismissed_reason_note = $request->input('note');
|
||||||
|
$failure->dismissed_by_user_id = $request->user()?->id;
|
||||||
|
$failure->save();
|
||||||
|
|
||||||
|
return new FormSubmissionActionFailureResource($failure->refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual model resolution. Implicit binding doesn't pick up nested-
|
||||||
|
* namespace ULID models reliably across nested route groups, so we
|
||||||
|
* load explicitly without global scopes (cross-tenant access reaches
|
||||||
|
* the policy, which then translates denied → 404 per RFC V3).
|
||||||
|
*/
|
||||||
|
private function resolveFailure(string $id): FormSubmissionActionFailure
|
||||||
|
{
|
||||||
|
$failure = FormSubmissionActionFailure::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->find($id);
|
||||||
|
if ($failure === null) {
|
||||||
|
throw new ModelNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a denied policy to 404 (RFC V3) so cross-tenant access
|
||||||
|
* does NOT confirm resource existence.
|
||||||
|
*/
|
||||||
|
private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void
|
||||||
|
{
|
||||||
|
if (Gate::denies($ability, $failure)) {
|
||||||
|
throw new ModelNotFoundException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
api/app/Http/Requests/FormBuilder/DismissFailureRequest.php
Normal file
45
api/app/Http/Requests/FormBuilder/DismissFailureRequest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\FormBuilder;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\DismissalReasonType;
|
||||||
|
use Illuminate\Contracts\Validation\Validator;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
final class DismissFailureRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason_type' => ['required', Rule::enum(DismissalReasonType::class)],
|
||||||
|
'note' => ['nullable', 'string', 'max:500'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $v): void {
|
||||||
|
if ($v->errors()->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$reason = DismissalReasonType::tryFrom((string) $this->input('reason_type'));
|
||||||
|
if ($reason === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($reason->requiresNote() && $this->input('note') === null) {
|
||||||
|
$v->errors()->add('note', __('form_builder.dismiss.note_required_for_other'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php
Normal file
25
api/app/Http/Requests/FormBuilder/ResolveFailureRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\FormBuilder;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ResolveFailureRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true; // policy enforced in controller via Gate::authorize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'note' => ['nullable', 'string', 'max:65535'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources\FormBuilder;
|
||||||
|
|
||||||
|
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin FormSubmissionActionFailure
|
||||||
|
*/
|
||||||
|
final class FormSubmissionActionFailureResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'form_submission_id' => $this->form_submission_id,
|
||||||
|
'binding_id' => $this->binding_id,
|
||||||
|
'listener_class' => $this->listener_class,
|
||||||
|
'failed_at' => $this->failed_at->toIso8601String(),
|
||||||
|
'exception_class' => $this->exception_class,
|
||||||
|
'exception_message' => $this->exception_message,
|
||||||
|
'context' => $this->context,
|
||||||
|
'retry_count' => $this->retry_count,
|
||||||
|
'resolved_at' => $this->resolved_at?->toIso8601String(),
|
||||||
|
'resolved_note' => $this->resolved_note,
|
||||||
|
'dismissed_at' => $this->dismissed_at?->toIso8601String(),
|
||||||
|
'dismissed_reason_type' => $this->dismissed_reason_type?->value,
|
||||||
|
'dismissed_reason_note' => $this->dismissed_reason_note,
|
||||||
|
'state' => match (true) {
|
||||||
|
$this->resolved_at !== null => 'resolved',
|
||||||
|
$this->dismissed_at !== null => 'dismissed',
|
||||||
|
default => 'open',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,11 +54,19 @@ final class FormSubmissionActionFailurePolicy
|
|||||||
|
|
||||||
private function canAccess(User $user, FormSubmissionActionFailure $failure): bool
|
private function canAccess(User $user, FormSubmissionActionFailure $failure): bool
|
||||||
{
|
{
|
||||||
$failure->loadMissing('submission');
|
// Load the submission without global scopes so cross-tenant
|
||||||
$submission = $failure->submission;
|
// resolution works for super_admin and so the policy itself
|
||||||
|
// does the tenant gating (RFC V3 — single source of truth for
|
||||||
|
// tenant resolution, not OrganisationScope).
|
||||||
|
$submission = \App\Models\FormBuilder\FormSubmission::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->find($failure->form_submission_id);
|
||||||
if ($submission === null) {
|
if ($submission === null) {
|
||||||
return false; // parent submission deleted
|
return false; // parent submission deleted
|
||||||
}
|
}
|
||||||
|
if ($submission->deleted_at !== null) {
|
||||||
|
return false; // soft-deleted parent — treat as gone
|
||||||
|
}
|
||||||
|
|
||||||
$orgId = (string) $submission->organisation_id;
|
$orgId = (string) $submission->organisation_id;
|
||||||
if ($orgId === '') {
|
if ($orgId === '') {
|
||||||
@@ -71,7 +79,9 @@ final class FormSubmissionActionFailurePolicy
|
|||||||
|
|
||||||
// Tenant scope: user must be an org_admin in the failure's
|
// Tenant scope: user must be an org_admin in the failure's
|
||||||
// organisation. RFC V3 — IDOR-class FK-chain enforcement.
|
// organisation. RFC V3 — IDOR-class FK-chain enforcement.
|
||||||
$organisation = $submission->organisation;
|
$organisation = \App\Models\Organisation::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->find($orgId);
|
||||||
if (! $organisation instanceof Organisation) {
|
if (! $organisation instanceof Organisation) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ use App\Observers\UserObserver;
|
|||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -120,6 +121,16 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->registerMorphMap();
|
$this->registerMorphMap();
|
||||||
|
|
||||||
|
// RFC-WS-6 V3 — explicit policy registration. Laravel's auto-discovery
|
||||||
|
// doesn't reliably resolve nested-namespace models like
|
||||||
|
// App\Models\FormBuilder\FormSubmissionActionFailure to
|
||||||
|
// App\Policies\FormBuilder\FormSubmissionActionFailurePolicy.
|
||||||
|
Gate::policy(
|
||||||
|
\App\Models\FormBuilder\FormSubmissionActionFailure::class,
|
||||||
|
\App\Policies\FormBuilder\FormSubmissionActionFailurePolicy::class,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
Person::observe(PersonObserver::class);
|
Person::observe(PersonObserver::class);
|
||||||
User::observe(UserObserver::class);
|
User::observe(UserObserver::class);
|
||||||
FormValue::observe(FormValueObserver::class);
|
FormValue::observe(FormValueObserver::class);
|
||||||
|
|||||||
9
api/lang/nl/form_builder.php
Normal file
9
api/lang/nl/form_builder.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dismiss' => [
|
||||||
|
'note_required_for_other' => 'Een toelichting is verplicht wanneer reden = "anders".',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -144,6 +144,13 @@ Route::prefix('admin')
|
|||||||
Route::get('impersonate/status', [AdminImpersonationController::class, 'status']);
|
Route::get('impersonate/status', [AdminImpersonationController::class, 'status']);
|
||||||
Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']);
|
Route::post('impersonate/send-mfa-code', [AdminImpersonationController::class, 'sendMfaCode']);
|
||||||
Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']);
|
Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']);
|
||||||
|
|
||||||
|
// RFC-WS-6 §3 (Q5) — platform-wide form-failure admin endpoints.
|
||||||
|
Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'platformIndex']);
|
||||||
|
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
@@ -235,6 +242,13 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () {
|
|||||||
// Email logs (read-only)
|
// Email logs (read-only)
|
||||||
Route::get('email-logs', [EmailLogController::class, 'index']);
|
Route::get('email-logs', [EmailLogController::class, 'index']);
|
||||||
|
|
||||||
|
// RFC-WS-6 §3 (Q5) — org-scoped form-failure admin endpoints.
|
||||||
|
Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'orgIndex']);
|
||||||
|
Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']);
|
||||||
|
Route::post('form-failures/{formSubmissionActionFailure}/dismiss', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'dismiss']);
|
||||||
|
|
||||||
// Person tags (organisation settings)
|
// Person tags (organisation settings)
|
||||||
Route::apiResource('person-tags', PersonTagController::class)
|
Route::apiResource('person-tags', PersonTagController::class)
|
||||||
->except(['show']);
|
->except(['show']);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder\Api;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\DismissalReasonType;
|
||||||
|
use App\Models\FormBuilder\FormSchema;
|
||||||
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
|
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-WS-6 §3 (Q5) + §4 (V3) — admin endpoints. Cross-tenant access
|
||||||
|
* MUST return 404, never 403, to prevent enumeration. The IDOR-class
|
||||||
|
* tests below assert this contract explicitly.
|
||||||
|
*/
|
||||||
|
final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $orgA;
|
||||||
|
|
||||||
|
private Organisation $orgB;
|
||||||
|
|
||||||
|
private User $superAdmin;
|
||||||
|
|
||||||
|
private User $orgAdminA;
|
||||||
|
|
||||||
|
private User $orgAdminB;
|
||||||
|
|
||||||
|
private FormSubmissionActionFailure $failureInOrgA;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->orgA = Organisation::factory()->create();
|
||||||
|
$this->orgB = Organisation::factory()->create();
|
||||||
|
|
||||||
|
$this->superAdmin = User::factory()->create();
|
||||||
|
$this->superAdmin->assignRole('super_admin');
|
||||||
|
|
||||||
|
$this->orgAdminA = User::factory()->create();
|
||||||
|
$this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$this->orgAdminB = User::factory()->create();
|
||||||
|
$this->orgB->users()->attach($this->orgAdminB, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$schema = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||||
|
$submission = FormSubmission::factory()->create([
|
||||||
|
'form_schema_id' => $schema->id,
|
||||||
|
'organisation_id' => $this->orgA->id,
|
||||||
|
]);
|
||||||
|
$this->failureInOrgA = FormSubmissionActionFailure::factory()
|
||||||
|
->for($submission, 'submission')
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unauthenticated_returns_401(): void
|
||||||
|
{
|
||||||
|
$this->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}")
|
||||||
|
->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_super_admin_can_view_failure(): void
|
||||||
|
{
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
Sanctum::actingAs($this->superAdmin); $this
|
||||||
|
->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', (string) $this->failureInOrgA->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_org_admin_can_view_own_org_failure(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdminA);
|
||||||
|
$this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}")
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC V3 IDOR-class — admin from org B must NOT see a failure
|
||||||
|
* whose submission belongs to org A. 404, NOT 403.
|
||||||
|
*/
|
||||||
|
public function test_cross_tenant_access_returns_404_not_403(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdminB); $this
|
||||||
|
->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}")
|
||||||
|
->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resolve_endpoint_with_note(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->superAdmin); $this
|
||||||
|
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/resolve", [
|
||||||
|
'note' => 'fixed via direct edit',
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.state', 'resolved')
|
||||||
|
->assertJsonPath('data.resolved_note', 'fixed via direct edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dismiss_endpoint_with_enum_reason(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->superAdmin); $this
|
||||||
|
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
|
||||||
|
'reason_type' => DismissalReasonType::SCHEMA_DELETED->value,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.state', 'dismissed')
|
||||||
|
->assertJsonPath('data.dismissed_reason_type', DismissalReasonType::SCHEMA_DELETED->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dismiss_other_without_note_fails_422(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->superAdmin); $this
|
||||||
|
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
|
||||||
|
'reason_type' => DismissalReasonType::OTHER->value,
|
||||||
|
])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['note']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cross_tenant_dismiss_returns_404(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdminB); $this
|
||||||
|
->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/dismiss", [
|
||||||
|
'reason_type' => DismissalReasonType::OTHER->value,
|
||||||
|
'note' => 'evil',
|
||||||
|
])
|
||||||
|
->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cross_tenant_resolve_returns_404(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->orgAdminB); $this
|
||||||
|
->postJson("/api/v1/organisations/{$this->orgA->id}/form-failures/{$this->failureInOrgA->id}/resolve", [])
|
||||||
|
->assertStatus(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user