WebAuthnLoginControllerTest.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. <?php
  2. namespace Tests\Feature\Http\Auth;
  3. use App\Extensions\WebauthnTwoFAuthUserProvider;
  4. use App\Http\Controllers\Auth\WebAuthnLoginController;
  5. use App\Http\Middleware\SkipIfAuthenticated;
  6. use App\Models\User;
  7. use Illuminate\Support\Facades\Config;
  8. use Illuminate\Support\Facades\DB;
  9. use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
  10. use Laragear\WebAuthn\Enums\UserVerification;
  11. use PHPUnit\Framework\Attributes\CoversClass;
  12. use PHPUnit\Framework\Attributes\CoversMethod;
  13. use PHPUnit\Framework\Attributes\Test;
  14. use Tests\FeatureTestCase;
  15. /**
  16. * WebAuthnLoginControllerTest test class
  17. */
  18. #[CoversClass(WebAuthnLoginController::class)]
  19. #[CoversClass(User::class)]
  20. #[CoversClass(WebauthnTwoFAuthUserProvider::class)]
  21. #[CoversMethod(SkipIfAuthenticated::class, 'handle')]
  22. class WebAuthnLoginControllerTest extends FeatureTestCase
  23. {
  24. /**
  25. * @var \App\Models\User
  26. */
  27. protected $user;
  28. /**
  29. * @var \App\Models\User
  30. */
  31. protected $admin;
  32. const CREDENTIAL_ID = 's06aG41wsIYh5X1YUhB-SlH8y3F2RzdJZVse8iXRXOCd3oqQdEyCOsBawzxrYBtJRQA2azAMEN_q19TUp6iMgg';
  33. const CREDENTIAL_ID_ALT = '-VOLFKPY-_FuMI_sJ7gMllK76L3VoRUINj6lL_Z3qDg';
  34. const CREDENTIAL_ID_ALT_RAW = '+VOLFKPY+/FuMI/sJ7gMllK76L3VoRUINj6lL/Z3qDg=';
  35. const PUBLIC_KEY = 'eyJpdiI6ImYyUHlJOEJML0pwTXJ2UDkveTQwZFE9PSIsInZhbHVlIjoiQWFSYi9LVEszazlBRUZsWHp0cGNRNktGeEQ3aTBsbU9zZ1g5MEgrWFJJNmgraElsNU9hV0VsRVlWc3NoUVVHUjRRdlcxTS9pVklnOWtVYWY5TFJQTTFhR1Rxb1ZzTFkxTWE4VUVvK1lyU3pYQ1M3VlBMWWxZcDVaYWFnK25iaXVyWGR6ZFRmMFVoSmdPZ3UvSnptbVZER0FYdEEyYmNYcW43RkV5aTVqSjNwZEFsUjhUYSs0YjU2Z2V2bUJXa0E0aVB1VC8xSjdJZ2llRGlHY2RwOGk3MmNPTyt6eDFDWUs1dVBOSWp1ZUFSeUlkclgwRW16RE9sUUpDSWV6Sk50TSIsIm1hYyI6IjI3ODQ5NzcxZGY1MzMwYTNiZjAwZmEwMDJkZjYzMGU4N2UzZjZlOGM0ZWE3NDkyYWMxMThhNmE5NWZiMTVjNGEiLCJ0YWciOiIifQ==';
  36. const USER_ID = '3b758ac868b74307a7e96e69ae187339';
  37. const USER_ID_ALT = 'e8af6f703f8042aa91c30cf72289aa07';
  38. const EMAIL = 'john.doe@example.com';
  39. const ASSERTION_RESPONSE = [
  40. 'id' => self::CREDENTIAL_ID_ALT,
  41. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  42. 'type' => 'public-key',
  43. 'response' => [
  44. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  45. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  46. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  47. 'userHandle' => self::USER_ID_ALT,
  48. ],
  49. 'email' => self::EMAIL,
  50. ];
  51. const ASSERTION_RESPONSE_NO_HANDLE = [
  52. 'id' => self::CREDENTIAL_ID_ALT,
  53. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  54. 'type' => 'public-key',
  55. 'response' => [
  56. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  57. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  58. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  59. 'userHandle' => null,
  60. ],
  61. 'email' => self::EMAIL,
  62. ];
  63. const ASSERTION_RESPONSE_INVALID = [
  64. 'id' => self::CREDENTIAL_ID_ALT,
  65. 'rawId' => self::CREDENTIAL_ID_ALT_RAW,
  66. 'type' => 'public-key',
  67. 'response' => [
  68. 'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
  69. 'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
  70. 'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
  71. 'userHandle' => self::USER_ID_ALT,
  72. ],
  73. 'email' => self::EMAIL,
  74. ];
  75. const ASSERTION_CHALLENGE = 'iXozmynKi+YD2iRvKNbSPA==';
  76. public function setUp() : void
  77. {
  78. parent::setUp();
  79. DB::table('users')->delete();
  80. }
  81. #[Test]
  82. public function test_webauthn_login_returns_success()
  83. {
  84. $this->user = User::factory()->create(['email' => self::EMAIL]);
  85. DB::table('webauthn_credentials')->insert([
  86. 'id' => self::CREDENTIAL_ID_ALT,
  87. 'authenticatable_type' => \App\Models\User::class,
  88. 'authenticatable_id' => $this->user->id,
  89. 'user_id' => self::USER_ID_ALT,
  90. 'counter' => 0,
  91. 'rp_id' => 'http://localhost',
  92. 'origin' => 'http://localhost',
  93. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  94. 'attestation_format' => 'none',
  95. 'public_key' => self::PUBLIC_KEY,
  96. 'updated_at' => now(),
  97. 'created_at' => now(),
  98. ]);
  99. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  100. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  101. 60,
  102. false,
  103. )]);
  104. $this->mock(AssertionValidator::class)
  105. ->expects('send->thenReturn')
  106. ->andReturn();
  107. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  108. ->assertOk()
  109. ->assertJsonFragment([
  110. 'message' => 'authenticated',
  111. 'id' => $this->user->id,
  112. 'name' => $this->user->name,
  113. 'email' => $this->user->email,
  114. 'is_admin' => false,
  115. ])
  116. ->assertJsonStructure([
  117. 'preferences',
  118. ]);
  119. }
  120. #[Test]
  121. public function test_webauthn_admin_login_returns_admin_role()
  122. {
  123. $this->admin = User::factory()->administrator()->create(['email' => self::EMAIL]);
  124. DB::table('webauthn_credentials')->insert([
  125. 'id' => self::CREDENTIAL_ID_ALT,
  126. 'authenticatable_type' => \App\Models\User::class,
  127. 'authenticatable_id' => $this->admin->id,
  128. 'user_id' => self::USER_ID_ALT,
  129. 'counter' => 0,
  130. 'rp_id' => 'http://localhost',
  131. 'origin' => 'http://localhost',
  132. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  133. 'attestation_format' => 'none',
  134. 'public_key' => self::PUBLIC_KEY,
  135. 'updated_at' => now(),
  136. 'created_at' => now(),
  137. ]);
  138. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  139. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  140. 60,
  141. false,
  142. )]);
  143. $this->mock(AssertionValidator::class)
  144. ->expects('send->thenReturn')
  145. ->andReturn();
  146. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  147. ->assertOk()
  148. ->assertJsonFragment([
  149. 'is_admin' => true,
  150. ]);
  151. }
  152. #[Test]
  153. public function test_webauthn_login_merge_handle_if_missing()
  154. {
  155. $this->user = User::factory()->create(['email' => self::EMAIL]);
  156. DB::table('webauthn_credentials')->insert([
  157. 'id' => self::CREDENTIAL_ID_ALT,
  158. 'authenticatable_type' => \App\Models\User::class,
  159. 'authenticatable_id' => $this->user->id,
  160. 'user_id' => self::USER_ID_ALT,
  161. 'counter' => 0,
  162. 'rp_id' => 'http://localhost',
  163. 'origin' => 'http://localhost',
  164. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  165. 'attestation_format' => 'none',
  166. 'public_key' => self::PUBLIC_KEY,
  167. 'updated_at' => now(),
  168. 'created_at' => now(),
  169. ]);
  170. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  171. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  172. 60,
  173. false,
  174. )]);
  175. $this->mock(AssertionValidator::class)
  176. ->expects('send->thenReturn')
  177. ->andReturn();
  178. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_NO_HANDLE)
  179. ->assertOk()
  180. ->assertJsonFragment([
  181. 'message' => 'authenticated',
  182. 'name' => $this->user->name,
  183. ])
  184. ->assertJsonStructure([
  185. 'message',
  186. 'name',
  187. 'preferences',
  188. ]);
  189. }
  190. #[Test]
  191. public function test_legacy_login_is_rejected_when_webauthn_only_is_enable()
  192. {
  193. $this->user = User::factory()->create([
  194. 'email' => self::EMAIL,
  195. ]);
  196. // Set to webauthn only
  197. $this->user['preferences->useWebauthnOnly'] = true;
  198. $this->user->save();
  199. $response = $this->json('POST', '/user/login', [
  200. 'email' => self::EMAIL,
  201. 'password' => 'password',
  202. ])
  203. ->assertUnauthorized();
  204. }
  205. #[Test]
  206. public function test_webauthn_login_already_authenticated_is_rejected()
  207. {
  208. $this->user = User::factory()->create(['email' => self::EMAIL]);
  209. DB::table('webauthn_credentials')->insert([
  210. 'id' => self::CREDENTIAL_ID_ALT,
  211. 'authenticatable_type' => \App\Models\User::class,
  212. 'authenticatable_id' => $this->user->id,
  213. 'user_id' => self::USER_ID_ALT,
  214. 'counter' => 0,
  215. 'rp_id' => 'http://localhost',
  216. 'origin' => 'http://localhost',
  217. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  218. 'attestation_format' => 'none',
  219. 'public_key' => self::PUBLIC_KEY,
  220. 'updated_at' => now(),
  221. 'created_at' => now(),
  222. ]);
  223. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  224. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  225. 60,
  226. false,
  227. )]);
  228. $this->mock(AssertionValidator::class)
  229. ->expects('send->thenReturn')
  230. ->andReturn();
  231. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  232. ->assertOk();
  233. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
  234. ->assertStatus(400)
  235. ->assertJsonStructure([
  236. 'message',
  237. ]);
  238. }
  239. #[Test]
  240. public function test_webauthn_login_with_missing_data_returns_validation_error()
  241. {
  242. $this->user = User::factory()->create(['email' => self::EMAIL]);
  243. $data = [
  244. 'id' => '',
  245. 'rawId' => '',
  246. 'type' => '',
  247. 'response' => [
  248. 'authenticatorData' => '',
  249. 'clientDataJSON' => '',
  250. 'signature' => '',
  251. 'userHandle' => null,
  252. ],
  253. ];
  254. $response = $this->json('POST', '/webauthn/login', $data)
  255. ->assertStatus(422)
  256. ->assertJsonValidationErrors([
  257. 'id',
  258. 'rawId',
  259. 'type',
  260. 'response.authenticatorData',
  261. 'response.clientDataJSON',
  262. 'response.signature',
  263. ]);
  264. }
  265. #[Test]
  266. public function test_webauthn_invalid_login_returns_unauthorized()
  267. {
  268. $this->user = User::factory()->create(['email' => self::EMAIL]);
  269. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  270. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  271. 60,
  272. false,
  273. )]);
  274. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  275. ->assertUnauthorized();
  276. }
  277. #[Test]
  278. public function test_too_many_invalid_login_attempts_returns_too_many_request_error()
  279. {
  280. $throttle = 8;
  281. Config::set('auth.throttle.login', $throttle);
  282. $this->user = User::factory()->create(['email' => self::EMAIL]);
  283. $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
  284. new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
  285. 60,
  286. false,
  287. )]);
  288. for ($i = 0; $i < $throttle - 1; $i++) {
  289. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
  290. }
  291. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  292. ->assertUnauthorized();
  293. $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID)
  294. ->assertStatus(429);
  295. }
  296. #[Test]
  297. public function test_get_options_returns_success()
  298. {
  299. Config::set('webauthn.user_verification', UserVerification::PREFERRED);
  300. $this->user = User::factory()->create(['email' => self::EMAIL]);
  301. DB::table('webauthn_credentials')->insert([
  302. 'id' => self::CREDENTIAL_ID,
  303. 'authenticatable_type' => \App\Models\User::class,
  304. 'authenticatable_id' => $this->user->id,
  305. 'user_id' => self::USER_ID,
  306. 'counter' => 0,
  307. 'rp_id' => 'http://localhost',
  308. 'origin' => 'http://localhost',
  309. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  310. 'attestation_format' => 'none',
  311. 'public_key' => self::PUBLIC_KEY,
  312. 'updated_at' => now(),
  313. 'created_at' => now(),
  314. ]);
  315. $response = $this->json('POST', '/webauthn/login/options', [
  316. 'email' => $this->user->email,
  317. ])
  318. ->assertOk()
  319. ->assertJsonStructure([
  320. 'challenge',
  321. 'timeout',
  322. ])
  323. ->assertJsonFragment([
  324. 'allowCredentials' => [[
  325. 'id' => self::CREDENTIAL_ID,
  326. 'type' => 'public-key',
  327. ]],
  328. ]);
  329. }
  330. #[Test]
  331. public function test_get_options_for_securelogin_returns_required_userVerification()
  332. {
  333. Config::set('webauthn.user_verification', UserVerification::REQUIRED);
  334. $this->user = User::factory()->create(['email' => self::EMAIL]);
  335. DB::table('webauthn_credentials')->insert([
  336. 'id' => self::CREDENTIAL_ID,
  337. 'authenticatable_type' => \App\Models\User::class,
  338. 'authenticatable_id' => $this->user->id,
  339. 'user_id' => self::USER_ID,
  340. 'counter' => 0,
  341. 'rp_id' => 'http://localhost',
  342. 'origin' => 'http://localhost',
  343. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  344. 'attestation_format' => 'none',
  345. 'public_key' => self::PUBLIC_KEY,
  346. 'updated_at' => now(),
  347. 'created_at' => now(),
  348. ]);
  349. $response = $this->json('POST', '/webauthn/login/options', [
  350. 'email' => $this->user->email,
  351. ])
  352. ->assertOk()
  353. ->assertJsonStructure([
  354. 'challenge',
  355. 'userVerification',
  356. 'timeout',
  357. ])
  358. ->assertJsonFragment([
  359. 'userVerification' => 'required',
  360. 'allowCredentials' => [[
  361. 'id' => self::CREDENTIAL_ID,
  362. 'type' => 'public-key',
  363. ]],
  364. ]);
  365. }
  366. #[Test]
  367. public function test_get_options_for_fastlogin_returns_discouraged_userVerification()
  368. {
  369. Config::set('webauthn.user_verification', UserVerification::DISCOURAGED);
  370. $this->user = User::factory()->create(['email' => self::EMAIL]);
  371. DB::table('webauthn_credentials')->insert([
  372. 'id' => self::CREDENTIAL_ID,
  373. 'authenticatable_type' => \App\Models\User::class,
  374. 'authenticatable_id' => $this->user->id,
  375. 'user_id' => self::USER_ID,
  376. 'counter' => 0,
  377. 'rp_id' => 'http://localhost',
  378. 'origin' => 'http://localhost',
  379. 'aaguid' => '00000000-0000-0000-0000-000000000000',
  380. 'attestation_format' => 'none',
  381. 'public_key' => self::PUBLIC_KEY,
  382. 'updated_at' => now(),
  383. 'created_at' => now(),
  384. ]);
  385. $response = $this->json('POST', '/webauthn/login/options', [
  386. 'email' => $this->user->email,
  387. ])
  388. ->assertOk()
  389. ->assertJsonStructure([
  390. 'challenge',
  391. 'userVerification',
  392. 'timeout',
  393. ])
  394. ->assertJsonFragment([
  395. 'userVerification' => 'discouraged',
  396. 'allowCredentials' => [[
  397. 'id' => self::CREDENTIAL_ID,
  398. 'type' => 'public-key',
  399. ]],
  400. ]);
  401. }
  402. #[Test]
  403. public function test_get_options_with_capitalized_email_returns_success()
  404. {
  405. $this->user = User::factory()->create(['email' => self::EMAIL]);
  406. $this->json('POST', '/webauthn/login/options', [
  407. 'email' => strtoupper($this->user->email),
  408. ])
  409. ->assertOk();
  410. }
  411. #[Test]
  412. public function test_get_options_with_missing_email_returns_validation_errors()
  413. {
  414. $this->json('POST', '/webauthn/login/options', [
  415. 'email' => null,
  416. ])
  417. ->assertStatus(422)
  418. ->assertJsonValidationErrors([
  419. 'email',
  420. ]);
  421. }
  422. #[Test]
  423. public function test_get_options_with_invalid_email_returns_validation_errors()
  424. {
  425. $this->json('POST', '/webauthn/login/options', [
  426. 'email' => 'invalid',
  427. ])
  428. ->assertStatus(422)
  429. ->assertJsonValidationErrors([
  430. 'email',
  431. ]);
  432. }
  433. #[Test]
  434. public function test_get_options_with_unknown_email_returns_validation_errors()
  435. {
  436. $this->json('POST', '/webauthn/login/options', [
  437. 'email' => 'john@example.com',
  438. ])
  439. ->assertStatus(422)
  440. ->assertJsonValidationErrors([
  441. 'email',
  442. ]);
  443. }
  444. }