ella-stinnes's avatar

ella-stinnes liked a comment+100 XP

1mo ago

You don’t need a ProjectUser model just to authorize “assigning a project to a user”.

In Laravel, the “pivot” is usually not treated as a first-class resource for authorization; instead you authorize the action on one of the real models (most commonly User or Project) and pass the other model(s) as arguments.

  1. Define a custom ability on UserPolicy
// app/Policies/UserPolicy.php

public function assignProject(User $actor, User $target, Project $project): bool
{
    // 1) Full Admin: can link users to projects within the user's company
    if ($actor->isFullAdmin()) {
        return $target->company_id === $project->company_id;
    }

    // 2) Company Admin: only within their own company
    if ($actor->isCompanyAdmin()) {
        return $actor->company_id === $target->company_id
            && $actor->company_id === $project->company_id;
    }

    return false;
}
  1. Use it in controller (best place, because you have all objects)
public function update(User $user, Request $request)
{
    $project = Project::findOrFail($request->project_id);

    $this->authorize('assignProject', [$user, $project]);

    $user->projects()->syncWithoutDetaching([$project->id]);
}

Route middleware: possible, but you’ll still need the objects

Route ->can() works well when your policy method can be resolved from route params. Since the ability needs User + Project, your route must provide both:

Route::post('/users/{user}/projects/{project}', [UserProjectController::class, 'store'])
    ->can('assignProject', ['user', 'project']);

This calls UserPolicy@assignProject(auth()->user(), $user, $project).

If your “edit” page doesn’t include {project} in the URL, then ->can() can only authorize a broader ability like “canManageUserProjects” (no specific project yet).
ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

1mo ago

My solution of a policy for a pivot table wasn't mentioned in the docs, so your explanation was what I was looking for and makes sense - thank you!

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

1mo ago

  • Full Admin - Can link any user to projects that are associated with the users' company.

  • Company Admin - Can only view users associated with their own company. They can therefore only link these users to projects associated with their company.

ella-stinnes's avatar

ella-stinnes started a new conversation+100 XP

1mo ago

  • projects
  • project_user
  • users

I want to create an authorization policy for the 'project_user' pivot table to define who is able to assign projects to a given user.

At the moment, I don't have a ProjectUser model class. Will I need to create this for the ProjectUserPolicy?

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

2mos ago

To check my understanding, I would define a gate for the page access as follows:

Gate::define('companies-index', function (User $user) {
        return $user->isRole([UserRole::Administrator, UserRole::CompanyAdministrator]);
    });

Then the policy would determine whether they can viewAny and/or view a specific model as follows?

A company administrator would not be able to viewany so a query scope would be applied to the index page query?

public function viewAny(User $user): bool
{
        return $user->isRole([UserRole::Administrator]);
}

public function view(User $user, Company $company): bool
{
		if ($user->isRole([UserRole::Administrator]))
            return true;
        
        if($user->isRole([UserRole::CompanyAdministrator])) {
            return $user->company_id === $company->id;

		return false;
}
ella-stinnes's avatar

ella-stinnes started a new conversation+100 XP

2mos ago

Both an administrator and company administrator should be able to access the companies.index page.

  • Administrator - should be able to list all companies.

  • Company Administrator - should only list the companies they are related to.

I've seen differing opinions on the policy viewAny method.

Should I be using this to define whether the user role can access the index page as I have below:

public function viewAny(User $user): bool
{
        return $user->isRole([UserRole::Administrator, UserRole::CompanyAdministrator]);
}

viewAny implies the CompanyAdministrator role can view any company.

Should I therefore be creating another method for allowing access to the index page (if so, what would you name this)?

ella-stinnes's avatar

ella-stinnes liked a comment+100 XP

2mos ago

@ella-stinnes Sounds like data providers and/or the “test with” utility would be a good solution for your use cases. They basically let you re-run a single test case multiple times, but with a different input each time.

The application has 8 different user roles, some users can access the full CRUD, some are read only and others cannot see the company details at all.

Should I be creating 8 tests for the different user roles?

Using a data provider, you’d have a single test case (e.g. test_create_company), that could take a user role and the expected result as inputs:

#[TestWith(['admin', true])]
#[TestWith(['moderator', true])]
#[TestWith(['member', false])]
// And your other roles...
public function test_create_company(string $role, bool $expected): void
{
    $user = User::factory()->role($role)->createOne();

    $this->assertDatabaseEmpty('companies');

    $response = $this->actingAs($user)->post('/companies', [
        // Valid company data...
    ]);

    if ($expected) {
        $response->assertRedirect()->assertValid();

        $this->assertDatabaseHas('companies', [
            // Fields and values that should exist in database...
        ]);
    } else {
        $response->assertForbidden();

        $this->assertDatabaseEmpty('companies');
    }
}

So the above test case will create a user with the given role and attempt to create a company. Then, depending on the expected result, will either assert the request was successful and a record was inserted into the database, or assert a 403 Forbidden response was returned and no company records were created.

Once I've tested the authorisation, I'm guessing I should have form validation tests - if there's 10 fields on the page, do I create 10 validation tests? Or one test to cover all 10 fields?

Again, you can use a data provider for say, testing required fields. For most resources in my applications, I‘ll have a test method that looks like this:

#[TestWith(['name'])]
#[TestWith(['description'])]
#[TestWith(['email'])]
#[TestWith(['telephone_number'])]
#[TestWith(['website_url'])]
public function test_field_is_required(string $field): void
{
    $user = User::factory()->role('admin')->createOne();

    $data = [
        'name' => fake()->company(),
        'email' => fake()->safeEmail(),
        // All other fields with a valid value...
    ];

    Arr::forget($data, $field);

    $this
        ->actingAs($user)
        ->post('/companies', $data)
        ->assertInvalid($field);
}

The above test case will then be re-ran multiple times with a different field name provided as an argument each time. The test case has an array of “valid” data, but then removes the named field, and asserts the request throws a validation error for that named field.

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

2mos ago

Thank you both, I'll look at the Pest Driven Laravel series although this appears to be TDD?

Does this cover a general rule of thumb as to what should be tested?

ella-stinnes's avatar

ella-stinnes started a new conversation+100 XP

2mos ago

I'm new to testing so I'm trying to figure out what I should test.

I have a CRUD for managing company details.

The application has 8 different user roles, some users can access the full CRUD, some are read only and others cannot see the company details at all.

Should I be creating 8 tests for the different user roles?

Once I've tested the authorisation, I'm guessing I should have form validation tests - if there's 10 fields on the page, do I create 10 validation tests? Or one test to cover all 10 fields?

Then there's the store/update methods, I'm guessing I assert the database contains the values.

Do you have a testing workflow for a standard CRUD as to what should be covered?

I'm not sure if I'm writing too many tests.

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

5mos ago

Thank you, I looked at the parsed view in the framework storage and the query is duplicated on the following two lines:

<?php $component = Illuminate\View\AnonymousComponent::resolve(['view' => 'components.input.field-select','data' => ['id' => 'company_id','options' => \App\Models\Company::orderBy('company_name')->pluck('company_name', 'id')->toArray()]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?>
<?php $component->withAttributes(['id' => 'company_id','options' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\App\Models\Company::orderBy('company_name')->pluck('company_name', 'id')->toArray())]); ?>

I'm running the latest version of the framework.

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

5mos ago

Thank you.

I know the drop down is currently used on the create and edit pages. It could potentially be required when filtering the index page or other areas of the system. Would the query then move to the model instead of the controller in this scenario (when other controllers require the same query)?

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

5mos ago

Makes sense. The select is used on both the create and edit forms, therefore should I place the query in a generic method within the controller or the model?

ella-stinnes's avatar

ella-stinnes wrote a reply+100 XP

5mos ago

Thank you, what would your approach be?

ella-stinnes's avatar

ella-stinnes started a new conversation+100 XP

6mos ago

I have the following select blade component:

@props([
    'id' => '', 
    'options' => array(), 
    ])

<select name="{{ $id }}" id="{{ $id }}">

    @foreach($options as $value => $name)
        <option value="{{ $value }}">{{ $name }}</option>
    @endforeach
    
</select>

I'm then using this component as follows:

<x-input.field-select id="company_id" :options="\App\Models\Company::orderBy('name')->pluck('name', 'id')->toArray()" />

When debugging, I'm finding that the query is executing twice, but I don't understand why?

Amending my usage to the following solves the issue, but I'd like to understand why the above doesn't work as I think it looks cleaner:

@php
$options = \App\Models\Company::orderBy('name')->pluck('name', 'id')->toArray();
@endphp

<x-input.field-select id="company_id" :options="$options" />