Lavarvel application running 7.10
I have a middleware that gets called on several routes. It check the user has the right relationships in place to get to the desired destination (that's not important).
I want a test that simply asserts that the user gets to the $next($request) in the middleware. I could of course just hit one of the end points, and assert something about that which would in turn validate the middleware, but all the routes hit external apis to get some other data which I could mock in my tests, which all seem pretty ugly just to test a middleware class.
I my test I can factory up the relevant relations etc, so whenever the middleware is called it should allow me straight in.
If I do this...
/** #test */
public function middleware_successful_test()
{
$user = factory(User::class)->create();
// some other factoried user relations here
$this->actingAs($user);
$request = new Request;
$middleware = app(MiddlewareClass::class);
$result = $middleware->handle($request, function ($req) {});
// don't know what assert here. $result is basically empty
}
Any tips, advise welcome.
Normally i would not unit test middlewares, but test it as a feature.
For your case i would utilize that you provide the next closure and you get the result. Return true and assert it is what is returned and therefor the closure has been called.
$result = $middleware->handle($request, function ($req) { return true;});
$this->assertSame(true, $result);
Related
So I created a middleware to limit the data a connected user has access to by adding global scopes depending on some informations:
public function handle(Request $request, Closure $next)
{
if (auth()->user()?->organization_id) {
User::addGlobalScope(new OrganizationScope(auth()->user()->organization));
}
return $next($request);
}
The middleware is added to the 'auth.group' middleware group in Kernel.php which is used in web.php:
Route::middleware(['auth.group'])->group(function () {
Route::resource('users', UserController::class);
});
Then in the controller, I would expect a user to get a 404 when trying to see a page of a user he has no rights to. But the $user is retrieved before the middleware applies the global scope!
public function show(User $user, Request $request) {
// dd($user); // <= This actually contains the User model! It shouldn't, of course.
// dd(User::find($user->id)); // <= null, as it should!
}
So, the dependency is apparently calculated before the middleware is applied. If I'm trying to move the middleware into the 'web' group in Kernel.php it's the same. And in the main $middleware array, the authenticated user's data is not available yet.
I found this discussion that seems to be on topic : https://github.com/laravel/framework/issues/44177 but the possible solutions (and Taylor's PR) seems to point to a solution in the controller itself. Not what I'm trying to do, or I can't see how to adapt it.
Before that I was applying the global scopes at the Model level, in the booted function (as shown in the docs). But I had lots of issues with that - namely, accessing a relationship from there to check what is allowed or not is problematic, as the relationship call will look for something in the Model itself, and said model is not ready (that's the point of the booted method, right...). For example, checking a relationship of the connected user on the User model has to be done with a direct query to the db, that will be ran every time the Model is called... Not good.
Anyway, I like the middleware approach as it is a clean way to deal with rights as well, I think. Any recommandation?
Not a recommendation, just my opinion.
This issue is just because of that Laravel allow you add middleware in controller constructor, and that's why it calculate before midddleware in your case.
I agree that middleware is a clean way to deal with auth, but i also think that you are not completely doing auth in your middleware, for example if you create a new route will you need to add something auth action into your new controller or just add auth middleware to route?
If does needs add something to controller, that means your auth middleware is just put some permissions info into global scope and you are doing the auth in controller which i think it's not right.
Controller should be only control the view logic, and you should do full auth in your auth middleware, once the request passed into your controller function that means user passed your auth.
For some example, if you auth permissions like below, you can just add auth middleware to new route without any action in your controller when you trying to create new route.
public function handle(Request $request, Closure $next)
{
if (auth()->user()->canView($request->route())) { // you should do full auth, not just add informations.
return $next($request);
}
else
abort(404);
}
So, I have created a test for my app, the model is Subscription which is the index and show endpoint should be publicly accessible.
I created a Resource Controller to handle I/O from the client and a Policy to handle the authorization but here, I found something that seems kinda odd.
Inside the controller, I registered the policy on the constructor, like so:
public function __construct()
{
$this->middleware('auth:api')->except(['index', 'show']);
$this->authorizeResource(Subscription::class, 'subscription');
}
And then in the policy class, I modified the default generated methods like so:
/**
* Determine whether the user can view any models.
*
* #param \App\Models\User $user
* #return mixed
*/
public function viewAny(?User $user) // <-- notice here I make it optional, the original was required (without "?" mark).
{
return true; // publicly visible
}
When I run the test, it passed.
public function testSubscriptionIndexArePubliclyAccessible()
{
$subscriptions = Subscription::factory(10)->create()->toArray();
$response = $this->get(route('subscriptions.index'));
$response->assertOk();
$response->assertExactJson($subscriptions);
}
However, if I completely remove the User $user param from the method, the test would fail.
public function viewAny() <-- if I do this, the test fail. Saying that "this action is unauthorized".
{
return true; // publicly visible
}
So.. Why is this happen?
There are checks happening before a policy method or gate ability are called. One check is if the policy method can be called with a user, canBeCalledWithUser. This will check if there is an auth user and return true, if not it does other checks. The next check is if the method allows guest users, methodAllowsGuests, which will use reflection to get the parameter for that method and see if it has a type and is nullable, but there are no parameters so it returns false. So you end up with it not calling the method and treating it more like it doesn't exist, which is always false in terms of authorization checks.
https://github.com/laravel/framework/blob/8.x/src/Illuminate/Auth/Access/Gate.php#L371 #raw -> callAuthCallback -> resolveAuthCallback
https://github.com/laravel/framework/blob/8.x/src/Illuminate/Auth/Access/Gate.php#L530 #resolveAuthCallback
https://github.com/laravel/framework/blob/8.x/src/Illuminate/Auth/Access/Gate.php#L390 #canBeCalledWithUser
https://github.com/laravel/framework/blob/8.x/src/Illuminate/Auth/Access/Gate.php#L416 #methodAllowsGuests
https://github.com/laravel/framework/blob/8.x/src/Illuminate/Auth/Access/Gate.php#L456 #parameterAllowsGuests - it does not make it to this method call
I am looking to create prettier URLs, and I'm having issues creating a valid route:
Let's say I have the following page http://localhost/app/account/5/edit.
This works fine with Route::get('account/{account}', 'AccountController#edit');
If I modify the Account Model and modify getRouteKeyName to return 'name', I am able to (with the same Route from above) access the following link: http://localhost/app/account/randomName/edit
The thing is, I am interested in a slightly different route, which is: http://localhost/app/account/randomName-5/edit
If I create a route Route::get('/accounts/{ignore}-{account}/edit', 'AccountController#edit'), it will fail as the first argument sent to edit is string and not an instance of Account. This can easily be fixed by modifying edit(Account $ac) to edit($ignored, Account $ac);... but it feels like cheating.
Is there a way to to get the route to ignore the first {block}?
According to the docs, you can customize your resolution logic for route model binding.
In this scenario, you can do something like this in App\Providers\RouteServiceProvider:
/**
* Bootstrap any application services.
*
* #return void
*/
public function boot()
{
parent::boot();
Route::bind('accountNameWithId', function ($value) {
list($accountName, $accountId) = explode('-', $value);
return App\Account::whereKey($accountId)
->where('name', $accountName)
->firstOrFail();
});
}
Then you can redefine your route like this:
Route::get('account/{accountNameWithId}', 'AccountController#edit');
I need to know how to assert that Laravel Controller returns view with proper data.
My simple controller function:
public function index() {
$users = User::all();
return view('user.index', ['users' => $users]);
}
I am using functions such as assertViewIs to get know if proper view file is loaded:
$response->assertViewIs('user.index');
Also using asserViewHas to know that "users" variable is taken:
$response->assertViewHas('users');
But I do not know how to assert if retrieve collection of users contain given users or not.
Thanks in advance.
In tests I would use the RefreshDatabase trait to get a clean database on each test. This allows you to create the data you need for that test and make assumptions on this data.
The test could then look something like this:
// Do not forget to use the RefreshDatabase trait in your test class.
use RefreshDatabase;
// ...
/** #test */
public function index_view_displays_users()
{
// Given: a list of users
factory(User::class, 5)->create();
// When: I visit the index page
$response = $this->get(route('index'));
// Then: I expect the view to have the correct users variable
$response->assertViewHas('users', User::all());
}
The key is to use the trait. When you now create the 5 dummy users with the factory, these will be the only ones in your database for that test, therefore the Users::all() call in your controller will return only those users.
I have middleware where I am assigning http headers to the request/response.
$response = $next($request)->header('x-robots-tag', 'noindex', false);
In the middleware, I can also apply this line after executing the above, to get the value I had just set...
echo $response->headers->get('x-robots-tag');
But, I want to access this outside of middleware but I'm not sure how to get the Response object back to achieve this.
How can I get the $response object from within my controller?
$response = \WHAT\GOES\HERE?;
echo $response->headers->get('x-robots-tag');
I can't seem to figure out what to put in the \WHAT\GOES\HERE part to access the response object again.
Update #1:
Still unresolved, but part of the problem appears to be that in order to add the header tags to the Response object within middleware requires $next($request) and the $next Closure causes the response processing to be done after the controller code has executed. So even though I'm not sure how to target the Response object from within the controller, it doesn't look like it would have the header tag assigned until afterward anyway.
I could set the headers directly in PHP in the middleware with
public function handle($request, Closure $next /*, $tags */)
{
$tags = array_except(func_get_args(), [0,1]);
if( count($tags) > 0){
header('x-robots-tag: ' . implode(', ', $tags));
}
return $next($request);
}
and then access it in the controller by pulling it out of the headers_list() but that's not ideal and working outside of the laravel ways...
For context, the idea was to assign middleware to routes and with the middleware assign the x-robots-tag response header with the desired attributes. noindex, nofollow, whatever... Then I was hoping to capture this and populate the equivalent meta tags accordingly using the data provided to the x-robots-tag. A two-birds with one stone sort of approach, but it has proven more difficult than I had expected.