refactor(form-builder): restore type-hinted route model binding for failures controller (WS-6)
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,8 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
public function test_super_admin_can_view_failure(): void
|
||||
{
|
||||
$this->withoutExceptionHandling();
|
||||
Sanctum::actingAs($this->superAdmin); $this
|
||||
Sanctum::actingAs($this->superAdmin);
|
||||
$this
|
||||
->getJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', (string) $this->failureInOrgA->id);
|
||||
@@ -91,14 +92,16 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
*/
|
||||
public function test_cross_tenant_access_returns_404_not_403(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdminB); $this
|
||||
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
|
||||
Sanctum::actingAs($this->superAdmin);
|
||||
$this
|
||||
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/resolve", [
|
||||
'note' => 'fixed via direct edit',
|
||||
])
|
||||
@@ -109,7 +112,8 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
|
||||
public function test_dismiss_endpoint_with_enum_reason(): void
|
||||
{
|
||||
Sanctum::actingAs($this->superAdmin); $this
|
||||
Sanctum::actingAs($this->superAdmin);
|
||||
$this
|
||||
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
|
||||
'reason_type' => DismissalReasonType::SCHEMA_DELETED->value,
|
||||
])
|
||||
@@ -120,7 +124,8 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
|
||||
public function test_dismiss_other_without_note_fails_422(): void
|
||||
{
|
||||
Sanctum::actingAs($this->superAdmin); $this
|
||||
Sanctum::actingAs($this->superAdmin);
|
||||
$this
|
||||
->postJson("/api/v1/admin/form-failures/{$this->failureInOrgA->id}/dismiss", [
|
||||
'reason_type' => DismissalReasonType::OTHER->value,
|
||||
])
|
||||
@@ -130,7 +135,8 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
|
||||
public function test_cross_tenant_dismiss_returns_404(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdminB); $this
|
||||
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',
|
||||
@@ -140,7 +146,8 @@ final class FormSubmissionActionFailureControllerTest extends TestCase
|
||||
|
||||
public function test_cross_tenant_resolve_returns_404(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdminB); $this
|
||||
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