Kontynuujemy serię wpisów, w której budujemy wspólnie aplikację bloga pisząc do niej testy automatyczne przy użyciu techniki Test Driven Development. W piątej części napisaliśmy testy dla funkcjonalności edytowania oraz usuwania wpisu. Napisaliśmy też testy sprawdzające walidację formularza edycji i przenieśliśmy reguły walidacji z kontrolera do dedykowanej klasy PostRequest
.
W tej części zajmiemy się autoryzacją zachowań użytkowników. W pierwszej kolejności napiszemy test sprawdzający, że użytkownicy mogą edytować tylko te wpisy, których są właścicielami. Do zaimplementowania tej funkcjonalności użyjemy klasy typu Policy
. Następnie napiszemy test sprawdzający, że usuwać wpisy będzie mógł tylko użytkownik oznaczony jako administrator.
Użytkownicy mogą edytować tylko swoje wpisy
W trzeciej części chcieliśmy przetestować, że tylko zalogowani użytkownicy mogą dodawać wpisy. W tym celu napisaliśmy test sprawdzający, że niezalogowani użytkownicy nie mogą dodawać wpisów. Podobną metodą sprawdzimy, że użytkownicy mogą edytować tylko swoje wpisy. Napiszemy test sprawdzający, że użytkownicy nie mogą edytować wpisów innych użytkowników.
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function a_user_cannot_update_other_users_posts()
{
$fred = factory(User::class)->create();
$barney = factory(User::class)->create();
$post = factory(Post::class)->create([
'user_id' => $fred->id,
]);
$response = $this->actingAs($barney)->patch("/posts/{$post->id}" , [
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
$response->assertForbidden();
}
W sekcji "arrange" testu tworzymy dwóch użytkowników, Freda i Barneya oraz wpis, którego właścicielem jest Fred. W sekcji "act" testu, za pomocą metody actingAs()
logujemy się do aplikacji jako Barney i próbujemy edytować wpis Freda. W ostatniej sekcji testu, używamy asercji assertForbidden()
, która sprawdza czy w odpowiedzi na nasze żądanie serwer zwrócił kod 403.
Gdy uruchomimy nasz test za pomocą komendy vendor/bin/phpunit --filter=a_user_cannot_update_other_users_posts
zobaczymy coś takiego:
Response status code [200] is not a forbidden status code.
Failed asserting that false is true.
Nie napisaliśmy jeszcze kodu, który uniemożliwiałby użytkownikowi edytowanie wpisu innego użytkownika, w związku z czym żądanie wykonane w teście przechodzi bez problemu i zwraca kod sukcesu 200, a nie tak jakbyśmy chcieli 403.
Laravel ma wbudowany prosty w użyciu mechanizm autoryzacji zachowań użytkowników. Do sprawdzenia czy zalogowany użytkownik ma prawo edytować dany wpis świetnie nadaje się klasa typu Policy
. Wygenerujmy klasę PostPolicy
dla naszego modelu Post
za pomocą komendy:
php artisan make:policy PostPolicy
Artisan stworzy nową klasę w katalogu app/Policies
. Zdefiniujmy w tej klasie metodę update()
, w której sprawdzimy czy użytkownik może edytować wpis. Metoda otrzyma jako parametry obiekt User
oraz Post
i powinna zwrócić true
lub false
. Jeśli zalogowany użytkownik może edytować podany wpis, powinna zwrócić true
. Jeśli nie, powinna zwrócić false
.
app/Policies/PostPolicy.php
namespace App\Policies;
use App\Post;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class PostPolicy
{
use HandlesAuthorization;
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
W metodzie update()
sprawdzamy czy id
zalogowanego użytkownika zgadza się z wartością parametru user_id
wpisu.
Gdy mamy już zdefiniowaną politykę modelu Post
, musimy jej jeszcze użyć w kodzie w miejscu, w którym chcemy sprawdzić czy użytkownik ma uprawnienia do wykonania danej operacji na wpisie. Można to zrobić za pomocą metody authorize()
w kontrolerze:
// app/Http/Controllers/PostController.php
public function update(PostRequest $request, Post $post)
{
$this->authorize('update', $post);
$post->update($request->only([
'published_at',
'title',
'body',
]));
}
Jako pierwszy parametr metody authorize()
podajemy nazwę metody z naszej polityki, jako drugi podajemy model. Laravel odnajdzie klasę PostPolicy
i jej metodę update()
, a następnie przekaże do niej dwa parametry: zalogowanego użytkownika i podany przez nas model. Następnie wykona jej kod. Jeśli ta metoda zwróci true
, to kod kontrolera przejdzie dalej i wpis zostanie zaktualizowany. Jeśli metoda zwróci false
to zostanie wyrzucony wyjątek, który zostanie następnie przerobiony przez ExceptionHandler na odpowiedź ze statusem 403.
Drobna uwaga: oprócz zdefiniowania klasy PostPolicy
, powinniśmy ją jeszcze zarejestrować, czyli poinstruować framework, że tej właśnie polityki ma używać, gdy chcemy sprawdzić uprawnienia użytkownika do wykonania danej operacji na modelu. Jednak począwszy od wersji 5.8 frameworka, pod warunkiem, że nazwaliśmy klasę zgodnie z konwencją, czyli nazwa modelu i suffix Policy
, rejestracja wykona się automatycznie i nie musimy się tym przejmować.
Test powinien teraz przejść. Jednocześnie zepsuliśmy jeden z testów, który napisaliśmy wcześniej, konkretnie ten:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function a_post_can_be_updated()
{
$post = factory(Post::class)->create();
$this->patch("/posts/{$post->id}" , [
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
}
W tym teście tworzymy wpis, który następnie próbujemy zaktualizować będąc zalogowanym jako inny użytkownik. Przeróbmy nazwę i kod tego testu, żeby był niejako symetrycznym odbiciem z dodanym przed chwilą nowym testem:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function a_user_can_update_their_own_posts()
{
$fred = factory(User::class)->create();
$post = factory(Post::class)->create([
'user_id' => $fred->id,
]);
$this->actingAs($fred)->patch("/posts/{$post->id}" , [
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'published_at' => '2019-11-19 12:00:00',
'title' => 'Odebrał żelazko zamiast telefonu',
'body' => 'Miał pomóc żonie, a skończyło się tragedią.',
]);
}
Teraz wszystkie testy powinny przechodzić.
Usuwać wpisy mogą tylko administratorzy
Zajmijmy się teraz funkcjonalnością polegającą na tym, że wpisy może usuwać tylko administrator. Musimy wymyślić sposób na to jak oznaczyć użytkownika jako administratora. Moglibyśmy w tym celu dodać w tabeli users
kolumnę typu boolean o nazwie is_admin
. Moglibyśmy stworzyć nową tabelę roles
i za pomocą tabeli role_user
stworzyć relację many-to-many pomiędzy rolami i użytkownikami. To jednak wykracza poza zakres tematu testowania, więc na potrzeby tego wpisu proponuję rozwiązanie dosyć prymitywne. Ustalmy, że administratorem jest użytkownik, którego adres email to "admin@example.com".
Napiszmy test sprawdzający, że użytkownik, który nie jest administratorem, nie może usuwać wpisów:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function non_admin_users_cannot_delete_posts()
{
$user = factory(User::class)->create([
'email' => 'user@example.com',
]);
$post = factory(Post::class)->create();
$response = $this->actingAs($user)->delete("/posts/{$post->id}");
$response->assertForbidden();
}
Na początku tworzymy zwykłego użytkownika oraz wpis. Następnie logujemy się jako ten użytkownik i próbujemy usunąć wpis. W ostatniej sekcji testu sprawdzamy, że serwer zwrócił odpowiedź z kodem 403.
Gdy uruchomimy test, powinniśmy zobaczyć:
Response status code [200] is not a forbidden status code.
Dodajmy w naszym kontrolerze w metodzie destroy()
, kod sprawdzający uprawnienia użytkownika do usuwania wpisu:
// app/Http/Controllers/PostController.php
public function destroy(Post $post)
{
$this->authorize('delete', $post);
$post->delete();
}
Instruujemy framework, żeby sprawdził w polityce PostPolicy
metodę delete()
. Dodajmy tę metodę do polityki:
// app/Policies/PostPolicy.php
public function delete(User $user, Post $post): bool
{
return $user->isAdmin();
}
Używamy tutaj metody isAdmin()
na modelu User
, której jeszcze nie mamy. Dodajmy ją:
// app/User.php
public function isAdmin(): bool
{
return $this->email === 'admin@example.com';
}
Te zmiany powinny sprawić, że nasz kod przejdzie. Podobnie jak w przypadku zmian dotyczących uprawnień do edytowania, zepsuliśmy jednak inny test, konkretnie ten:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function a_post_can_be_deleted()
{
$post = factory(Post::class)->create();
$this->delete("/posts/{$post->id}");
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
}
Przeróbmy nazwę i kod tego testu:
// tests/Feature/Authenticated/PostsTest.php
/** @test */
public function admin_can_delete_posts()
{
$admin = factory(User::class)->create([
'email' => 'admin@example.com',
]);
$post = factory(Post::class)->create();
$this->actingAs($admin)->delete("/posts/{$post->id}");
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
}
Gdy uruchomimy wszystkie testy powinniśmy zobaczyć:
W tej szóstej i ostatniej części serii wpisów z tematu "Wprowadzenie do testowania w Laravelu" napisaliśmy test sprawdzający, że użytkownicy mogą edytować tylko te wpisy, których są właścicielami. Napisaliśmy też test sprawdzający, że usuwać wpisy może tylko użytkownik oznaczony jako administrator. Do sprawdzenia uprawnień użyliśmy klasy PostPolicy
.
Kod napisany w tym wpisie znajdziecie na GitHubie.
Ogółem napisaliśmy w tej serii 28 testów, w których sprawdzamy funkcjonalności naszej aplikacji. Sprawdzamy w nich, że na liście wpisów wyświetlają się tylko te oznaczone jako opublikowane. Sprawdzamy że tylko zalogowani użytkownicy mogą dodawać wpisy, edytować wpisy mogą tylko ich autorzy i wreszcie, że usuwać wpisy może tylko administrator. Mamy też testy sprawdzające, że formularze dodawania i edytowania wpisu są w odpowiedni sposób walidowane. Warto na tym etapie zwrócić uwagę na to, że wszystkie te funkcjonalności napisaliśmy i przetestowaliśmy w naszej aplikacji nie otwierając nawet okna przeglądarki.
Omówiliśmy tematy związane z samym testowaniem, jak różnice między testami funkcjonalnymi a jednostkowymi, automatyczne migrowanie bazy danych, przed każdym testem, użycie fabryk modeli czy logowanie użytkowników w testach. Przy okazji omówiliśmy kilka mechanizmów Laravela, takich jak użycie klas typu FormRequest
czy Policy
.
Dajcie znać w komentarzach czego jeszcze chcielibyście się dowiedzieć w temacie automatycznego testowania aplikacji postawionych na Laravelu.
Jak zwykle, jeśli macie jakiekolwiek pytania dotyczące tego wpisu lub całej serii, zapraszamy do komentarzy poniżej lub na naszego Slacka.