Prechádzať zdrojové kódy

Enhance test coverage

Bubka 2 rokov pred
rodič
commit
9c5f18bb46

+ 1 - 1
app/Extensions/WebauthnTwoFAuthUserProvider.php

@@ -21,7 +21,7 @@ class WebauthnTwoFAuthUserProvider extends WebAuthnUserProvider
             return $this->validateWebAuthn();
         }
 
-        // If the user disabled the fallback is enabled, we will validate the credential password.
+        // If the user disabled the fallback, we will validate the credential password.
         return $user->preferences['useWebauthnOnly'] == false && EloquentUserProvider::validateCredentials($user, $credentials);
     }
 }

+ 45 - 45
app/Policies/GroupPolicy.php

@@ -17,10 +17,10 @@ class GroupPolicy
      * @param  \App\Models\User  $user
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function viewAny(User $user)
-    {
-        return false;
-    }
+    // public function viewAny(User $user)
+    // {
+    //     return false;
+    // }
 
     /**
      * Determine whether the user can view the model.
@@ -48,19 +48,19 @@ class GroupPolicy
      * @param  \Illuminate\Support\Collection<int, \App\Models\Group>  $groups
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function viewEach(User $user, Group $group, $groups)
-    {
-        $can = $this->isOwnerOfEach($user, $groups);
+    // public function viewEach(User $user, Group $group, $groups)
+    // {
+    //     $can = $this->isOwnerOfEach($user, $groups);
 
-        if (! $can) {
-            $ids = $groups->map(function ($group, $key) {
-                return $group->id;
-            });
-            Log::notice(sprintf('User ID #%s cannot view all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
-        }
+    //     if (! $can) {
+    //         $ids = $groups->map(function ($group, $key) {
+    //             return $group->id;
+    //         });
+    //         Log::notice(sprintf('User ID #%s cannot view all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
+    //     }
 
-        return $can;
-    }
+    //     return $can;
+    // }
 
     /**
      * Determine whether the user can create models.
@@ -101,19 +101,19 @@ class GroupPolicy
      * @param  \Illuminate\Support\Collection<int, \App\Models\Group>  $groups
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function updateEach(User $user, Group $group, $groups)
-    {
-        $can = $this->isOwnerOfEach($user, $groups);
+    // public function updateEach(User $user, Group $group, $groups)
+    // {
+    //     $can = $this->isOwnerOfEach($user, $groups);
 
-        if (! $can) {
-            $ids = $groups->map(function ($group, $key) {
-                return $group->id;
-            });
-            Log::notice(sprintf('User ID #%s cannot update all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
-        }
+    //     if (! $can) {
+    //         $ids = $groups->map(function ($group, $key) {
+    //             return $group->id;
+    //         });
+    //         Log::notice(sprintf('User ID #%s cannot update all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
+    //     }
 
-        return $can;
-    }
+    //     return $can;
+    // }
 
     /**
      * Determine whether the user can delete the model.
@@ -141,19 +141,19 @@ class GroupPolicy
      * @param  \Illuminate\Support\Collection<int, \App\Models\Group>  $groups
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function deleteEach(User $user, Group $group, $groups)
-    {
-        $can = $this->isOwnerOfEach($user, $groups);
+    // public function deleteEach(User $user, Group $group, $groups)
+    // {
+    //     $can = $this->isOwnerOfEach($user, $groups);
 
-        if (! $can) {
-            $ids = $groups->map(function ($group, $key) {
-                return $group->id;
-            });
-            Log::notice(sprintf('User ID #%s cannot delete all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
-        }
+    //     if (! $can) {
+    //         $ids = $groups->map(function ($group, $key) {
+    //             return $group->id;
+    //         });
+    //         Log::notice(sprintf('User ID #%s cannot delete all groups in IDs #%s', $user->id, implode(',', $ids->toArray())));
+    //     }
 
-        return $can;
-    }
+    //     return $can;
+    // }
 
     /**
      * Determine whether the user can restore the model.
@@ -162,10 +162,10 @@ class GroupPolicy
      * @param  \App\Models\Group  $group
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function restore(User $user, Group $group)
-    {
-        return $this->isOwnerOf($user, $group);
-    }
+    // public function restore(User $user, Group $group)
+    // {
+
+    // }
 
     /**
      * Determine whether the user can permanently delete the model.
@@ -174,8 +174,8 @@ class GroupPolicy
      * @param  \App\Models\Group  $group
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function forceDelete(User $user, Group $group)
-    {
-        return $this->isOwnerOf($user, $group);
-    }
+    // public function forceDelete(User $user, Group $group)
+    // {
+
+    // }
 }

+ 12 - 12
app/Policies/TwoFAccountPolicy.php

@@ -17,10 +17,10 @@ class TwoFAccountPolicy
      * @param  \App\Models\User  $user
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function viewAny(User $user)
-    {
-        return false;
-    }
+    // public function viewAny(User $user)
+    // {
+    //     return false;
+    // }
 
     /**
      * Determine whether the user can view the model.
@@ -162,10 +162,10 @@ class TwoFAccountPolicy
      * @param  \App\Models\TwoFAccount  $twofaccount
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function restore(User $user, TwoFAccount $twofaccount)
-    {
-        return $this->isOwnerOf($user, $twofaccount);
-    }
+    // public function restore(User $user, TwoFAccount $twofaccount)
+    // {
+
+    // }
 
     /**
      * Determine whether the user can permanently delete the model.
@@ -174,8 +174,8 @@ class TwoFAccountPolicy
      * @param  \App\Models\TwoFAccount  $twofaccount
      * @return \Illuminate\Auth\Access\Response|bool
      */
-    public function forceDelete(User $user, TwoFAccount $twofaccount)
-    {
-        return $this->isOwnerOf($user, $twofaccount);
-    }
+    // public function forceDelete(User $user, TwoFAccount $twofaccount)
+    // {
+
+    // }
 }

+ 1 - 1
app/Services/Migrators/TwoFAuthMigrator.php

@@ -45,7 +45,7 @@ class TwoFAuthMigrator extends Migrator
 
         if (is_null($json)) {
             Log::error('2FAuth JSON migration data cannot be read');
-            throw new InvalidMigrationDataException('2FAS Auth');
+            throw new InvalidMigrationDataException('2FAuth');
         }
 
         $twofaccounts = [];

+ 26 - 0
tests/Api/v1/Controllers/GroupControllerTest.php

@@ -10,6 +10,9 @@ use Tests\FeatureTestCase;
 /**
  * @covers \App\Api\v1\Controllers\GroupController
  * @covers \App\Api\v1\Resources\GroupResource
+ * @covers \App\Listeners\ResetUsersPreference
+ * @covers \App\Policies\GroupPolicy
+ * @covers \App\Models\Group
  */
 class GroupControllerTest extends FeatureTestCase
 {
@@ -444,4 +447,27 @@ class GroupControllerTest extends FeatureTestCase
                 'message',
             ]);
     }
+
+    /**
+     * @test
+     */
+    public function test_destroy_group_resets_user_preferences()
+    {
+        // Set the default group to a specific one
+        $this->user['preferences->defaultGroup'] = $this->userGroupA->id;
+        // Set the active group
+        $this->user['preferences->activeGroup'] = $this->userGroupA->id;
+        $this->user->save();
+
+        $this->assertEquals($this->userGroupA->id, $this->user->preferences['defaultGroup']);
+        $this->assertEquals($this->userGroupA->id, $this->user->preferences['activeGroup']);
+
+        $this->actingAs($this->user, 'api-guard')
+            ->json('DELETE', '/api/v1/groups/' . $this->userGroupA->id);
+
+        $this->user->refresh();
+
+        $this->assertEquals(0, $this->user->preferences['defaultGroup']);
+        $this->assertEquals(0, $this->user->preferences['activeGroup']);
+    }
 }

+ 84 - 2
tests/Api/v1/Controllers/TwoFAccountControllerTest.php

@@ -15,10 +15,14 @@ use Tests\FeatureTestCase;
 
 /**
  * @covers \App\Api\v1\Controllers\TwoFAccountController
+ * @covers \App\Api\v1\Resources\TwoFAccountCollection
  * @covers \App\Api\v1\Resources\TwoFAccountReadResource
  * @covers \App\Api\v1\Resources\TwoFAccountStoreResource
+ * @covers \App\Api\v1\Resources\TwoFAccountExportResource
+ * @covers \App\Api\v1\Resources\TwoFAccountExportCollection
  * @covers \App\Providers\MigrationServiceProvider
  * @covers \App\Providers\TwoFAuthServiceProvider
+ * @covers \App\Policies\TwoFAccountPolicy
  */
 class TwoFAccountControllerTest extends FeatureTestCase
 {
@@ -91,6 +95,27 @@ class TwoFAccountControllerTest extends FeatureTestCase
         'counter',
     ];
 
+    private const VALID_EXPORT_STRUTURE = [
+        'app',
+        'schema',
+        'datetime',
+        'data' => [
+            '*' => [
+                'otp_type',
+                'account',
+                'service',
+                'icon',
+                'icon_mime',
+                'icon_file',
+                'secret',
+                'digits',
+                'algorithm',
+                'period',
+                'counter',
+                'legacy_uri',
+            ], ],
+    ];
+
     private const JSON_FRAGMENTS_FOR_CUSTOM_TOTP = [
         'service'   => OtpTestData::SERVICE,
         'account'   => OtpTestData::ACCOUNT,
@@ -868,6 +893,65 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ]);
     }
 
+    /**
+     * @test
+     */
+    public function test_export_returns_json_migration_resource()
+    {
+        $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP);
+        $this->twofaccountB = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP);
+
+        $this->actingAs($this->user, 'api-guard')
+            ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id)
+            ->assertOk()
+            ->assertJsonStructure(self::VALID_EXPORT_STRUTURE)
+            ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP)
+            ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP);
+    }
+
+    /**
+     * @test
+     */
+    public function test_export_too_many_ids_returns_bad_request()
+    {
+        TwoFAccount::factory()->count(102)->for($this->user)->create();
+
+        $ids = DB::table('twofaccounts')->where('user_id', $this->user->id)->pluck('id')->implode(',');
+
+        $response = $this->actingAs($this->user, 'api-guard')
+            ->json('GET', '/api/v1/twofaccounts/export?ids=' . $ids)
+            ->assertStatus(400)
+            ->assertJsonStructure([
+                'message',
+                'reason',
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_export_missing_twofaccount_returns_existing_ones_only()
+    {
+        $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP);
+
+        $response = $this->actingAs($this->user, 'api-guard')
+            ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',1000')
+            ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP);
+    }
+
+    /**
+     * @test
+     */
+    public function test_export_twofaccount_of_another_user_is_forbidden()
+    {
+        $response = $this->actingAs($this->user, 'api-guard')
+        ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountC->id)
+            ->assertForbidden()
+            ->assertJsonStructure([
+                'message',
+            ]);
+    }
+
     /**
      * @test
      */
@@ -1155,8 +1239,6 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         TwoFAccount::factory()->count(3)->for($this->user)->create();
 
-        $ids = DB::table('twofaccounts')->where('user_id', $this->user->id)->pluck('id')->implode(',');
-
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('DELETE', '/api/v1/twofaccounts?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id)
             ->assertNoContent();

+ 246 - 1
tests/Data/MigrationTestData.php

@@ -106,7 +106,7 @@ class MigrationTestData
                         "name": "' . OtpTestData::ACCOUNT . '",
                         "issuer": "' . OtpTestData::SERVICE . '",
                         "note": "",
-                        "icon": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo=",
+                        "icon": "' . OtpTestData::ICON_SVG_DATA_ENCODED . '",
                         "icon_mime": "image\/svg+xml",
                         "info": {
                             "secret": "' . OtpTestData::SECRET . '",
@@ -463,4 +463,249 @@ class MigrationTestData
         },
         "db": "1rX0ajzsxNbhN2hvnNCMBNooLlzqwz\/LMT3bNEIJjPH+zIvIbA6GVVPHLpna+yvjxLPKVkt1OQig=="
     }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": null,
+                    "icon_mime": null,
+                    "icon_file": null,
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                },
+                {
+                    "otp_type": "hotp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": null,
+                    "icon_mime": null,
+                    "icon_file": null,
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": null,
+                    "counter": ' . OtpTestData::COUNTER_CUSTOM . ',
+                    "legacy_uri": "' . OtpTestData::HOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                },
+                {
+                    "otp_type": "steamtotp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::STEAM . '",
+                    "icon": null,
+                    "icon_mime": null,
+                    "icon_file": null,
+                    "secret": "' . OtpTestData::STEAM_SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_STEAM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::STEAM_TOTP_URI . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": null,
+                    "icon_mime": null,
+                    "icon_file": null,
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                },
+                {
+                    "otp_type": "Xotp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": null,
+                    "icon_mime": null,
+                    "icon_file": null,
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_SVG . '",
+                    "icon_mime": "image\/svg+xml",
+                    "icon_file": "' . OtpTestData::ICON_SVG_DATA_ENCODED . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_JPEG . '",
+                    "icon_mime": "image\/svg+xml",
+                    "icon_file": "' . OtpTestData::ICON_JPEG_DATA . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_PNG . '",
+                    "icon_mime": "image\/svg+xml",
+                    "icon_file": "' . OtpTestData::ICON_PNG_DATA . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_BMP_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_BMP . '",
+                    "icon_mime": "image\/svg+xml",
+                    "icon_file": "' . OtpTestData::ICON_BMP_DATA . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_WEBP_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_WEBP . '",
+                    "icon_mime": "image\/svg+xml",
+                    "icon_file": "' . OtpTestData::ICON_WEBP_DATA . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+        }';
+
+    const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                {
+                    "otp_type": "totp",
+                    "account": "' . OtpTestData::ACCOUNT . '",
+                    "service": "' . OtpTestData::SERVICE . '",
+                    "icon": "' . OtpTestData::ICON_PNG . '",
+                    "icon_mime": "image\/gif",
+                    "icon_file": "' . OtpTestData::ICON_PNG_DATA . '",
+                    "secret": "' . OtpTestData::SECRET . '",
+                    "digits": ' . OtpTestData::DIGITS_CUSTOM . ',
+                    "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '",
+                    "period": ' . OtpTestData::PERIOD_CUSTOM . ',
+                    "counter": null,
+                    "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '"
+                }
+            ]
+    }';
+
+    const INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD = '
+        {
+            "app": "2fauth_v3.4.1",
+            "schema": 1,
+            "datetime": "2022-12-14T14:53:06.173939Z",
+            "data":
+            [
+                ,
+            ]
+        }';
 }

+ 2 - 0
tests/Data/OtpTestData.php

@@ -54,6 +54,8 @@ class OtpTestData
 
     const ICON_SVG_DATA = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="512" style="fill:#000e9c"/><path d="m700.2 466.5 61.2-106.3c23.6 41.6 37.2 89.8 37.2 141.1 0 68.8-24.3 131.9-64.7 181.4H575.8l48.7-84.6h-64.4l75.8-131.7 64.3.1zm-55.4-125.2L448.3 682.5l.1.2H290.1c-40.5-49.5-64.7-112.6-64.7-181.4 0-51.4 13.6-99.6 37.3-141.3l102.5 178.2 113.3-197h166.3z" style="fill:#fff"/></svg>';
 
+    const ICON_SVG_DATA_ENCODED = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo=';
+
     const TOTP_FULL_CUSTOM_URI_NO_IMG = 'otpauth://totp/' . self::SERVICE . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE . '&digits=' . self::DIGITS_CUSTOM . '&period=' . self::PERIOD_CUSTOM . '&algorithm=' . self::ALGORITHM_CUSTOM;
 
     const TOTP_FULL_CUSTOM_URI = self::TOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::IMAGE;

+ 1 - 0
tests/Feature/Http/Auth/RegisterControllerTest.php

@@ -7,6 +7,7 @@ use Tests\FeatureTestCase;
 
 /**
  * @covers  \App\Http\Controllers\Auth\RegisterController
+ * @covers  \App\Http\Requests\UserStoreRequest
  */
 class RegisterControllerTest extends FeatureTestCase
 {

+ 32 - 5
tests/Feature/Http/Auth/UserControllerTest.php

@@ -2,7 +2,6 @@
 
 namespace Tests\Feature\Http\Auth;
 
-use App\Facades\Settings;
 use App\Models\Group;
 use App\Models\TwoFAccount;
 use App\Models\User;
@@ -12,6 +11,7 @@ use Tests\FeatureTestCase;
 /**
  * @covers  \App\Http\Controllers\Auth\UserController
  * @covers  \App\Http\Middleware\RejectIfDemoMode
+ * @covers  \App\Http\Requests\UserUpdateRequest
  */
 class UserControllerTest extends FeatureTestCase
 {
@@ -63,6 +63,33 @@ class UserControllerTest extends FeatureTestCase
         ]);
     }
 
+    /**
+     * @test
+     */
+    public function test_update_user_with_uppercased_email_returns_success()
+    {
+        $response = $this->actingAs($this->user, 'web-guard')
+            ->json('PUT', '/user', [
+                'name'     => self::NEW_USERNAME,
+                'email'    => strtoupper(self::NEW_EMAIL),
+                'password' => self::PASSWORD,
+            ])
+            ->assertOk()
+            ->assertExactJson([
+                'name'     => self::NEW_USERNAME,
+                'id'       => $this->user->id,
+                'email'    => self::NEW_EMAIL,
+                'is_admin' => false,
+            ]);
+
+        $this->assertDatabaseHas('users', [
+            'name'     => self::NEW_USERNAME,
+            'id'       => $this->user->id,
+            'email'    => self::NEW_EMAIL,
+            'is_admin' => false,
+        ]);
+    }
+
     /**
      * @test
      */
@@ -70,7 +97,7 @@ class UserControllerTest extends FeatureTestCase
     {
         Config::set('2fauth.config.isDemoApp', true);
 
-        $name = $this->user->name;
+        $name  = $this->user->name;
         $email = $this->user->email;
 
         $response = $this->actingAs($this->user, 'web-guard')
@@ -88,9 +115,9 @@ class UserControllerTest extends FeatureTestCase
             ]);
 
         $this->assertDatabaseHas('users', [
-            'name'     => $name,
-            'id'       => $this->user->id,
-            'email'    => $email,
+            'name'  => $name,
+            'id'    => $this->user->id,
+            'email' => $email,
         ]);
     }
 

+ 26 - 5
tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php

@@ -12,6 +12,7 @@ use Tests\FeatureTestCase;
 /**
  * @covers  \App\Http\Controllers\Auth\WebAuthnLoginController
  * @covers  \App\Models\User
+ * @covers  \App\Extensions\WebauthnTwoFAuthUserProvider
  */
 class WebAuthnLoginControllerTest extends FeatureTestCase
 {
@@ -120,8 +121,8 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
         $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
             ->assertOk()
             ->assertJsonFragment([
-                'message'     => 'authenticated',
-                'name'        => $this->user->name,
+                'message' => 'authenticated',
+                'name'    => $this->user->name,
             ])
             ->assertJsonStructure([
                 'message',
@@ -175,6 +176,26 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
             ]);
     }
 
+    /**
+     * @test
+     */
+    public function test_legacy_login_is_rejected_when_webauthn_only_is_enable()
+    {
+        $this->user = User::factory()->create([
+            'email' => self::EMAIL,
+        ]);
+
+        // Set to webauthn only
+        $this->user['preferences->useWebauthnOnly'] = true;
+        $this->user->save();
+
+        $response = $this->json('POST', '/user/login', [
+            'email'    => self::EMAIL,
+            'password' => 'password',
+        ])
+            ->assertUnauthorized();
+    }
+
     /**
      * @test
      *
@@ -215,8 +236,8 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
         $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE)
             ->assertOk()
             ->assertJsonFragment([
-                'message'     => 'authenticated',
-                'name'        => $this->user->name,
+                'message' => 'authenticated',
+                'name'    => $this->user->name,
             ])
             ->assertJsonStructure([
                 'message',
@@ -289,7 +310,7 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
             false,
         )]);
 
-        for ($i=0; $i < $throttle - 1; $i++) {
+        for ($i = 0; $i < $throttle - 1; $i++) {
             $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID);
         }
 

+ 54 - 0
tests/Feature/Http/Middlewares/AdminOnlyMiddlewareTest.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Tests\Feature\Http\Middlewares;
+
+use App\Http\Middleware\AdminOnly;
+use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Http\Request;
+use Tests\FeatureTestCase;
+
+class AdminOnlyMiddlewareTest extends FeatureTestCase
+{
+    /**
+     * @test
+     */
+    public function test_users_are_rejected()
+    {
+        $this->expectException(AuthorizationException::class);
+
+        /**
+         * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
+         */
+        $user = User::factory()->create();
+
+        $this->actingAs($user);
+
+        $request    = Request::create('/admin', 'GET');
+        $middleware = new AdminOnly;
+
+        $response = $middleware->handle($request, function () {
+        });
+    }
+
+    /**
+     * @test
+     */
+    public function test_admins_pass()
+    {
+        /**
+         * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
+         */
+        $admin = User::factory()->administrator()->create();
+
+        $this->actingAs($admin);
+
+        $request    = Request::create('/admin', 'GET');
+        $middleware = new AdminOnly;
+
+        $response = $middleware->handle($request, function () {
+        });
+
+        $this->assertNull($response);
+    }
+}

+ 80 - 0
tests/Feature/Http/Requests/WebauthnAssertedRequestTest.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace Tests\Feature\Http\Requests;
+
+use App\Http\Requests\WebauthnAssertedRequest;
+use Illuminate\Foundation\Testing\WithoutMiddleware;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+/**
+ * @covers \App\Http\Requests\WebauthnAssertedRequest
+ */
+class WebauthnAssertedRequestTest extends TestCase
+{
+    use WithoutMiddleware;
+
+    /**
+     * @dataProvider provideValidData
+     */
+    public function test_valid_data(array $data) : void
+    {
+        $request   = new WebauthnAssertedRequest();
+        $validator = Validator::make($data, $request->rules());
+
+        $this->assertFalse($validator->fails());
+    }
+
+    /**
+     * Provide Valid data for validation test
+     */
+    public function provideValidData() : array
+    {
+        return [
+            [[
+                'id'       => 'string',
+                'rawId'    => 'string',
+                'type'     => 'string',
+                'response' => [
+                    'clientDataJSON'    => 'string',
+                    'authenticatorData' => 'string',
+                    'signature'         => 'string',
+                    'userHandle'        => null,
+                ],
+                'email' => 'valid@email.com',
+            ]],
+        ];
+    }
+
+    /**
+     * @dataProvider provideInvalidData
+     */
+    public function test_invalid_data(array $data) : void
+    {
+        $request   = new WebauthnAssertedRequest();
+        $validator = Validator::make($data, $request->rules());
+
+        $this->assertTrue($validator->fails());
+    }
+
+    /**
+     * Provide invalid data for validation test
+     */
+    public function provideInvalidData() : array
+    {
+        return [
+            [[
+                'email' => '', // required
+            ]],
+            [[
+                'email' => true, // email
+            ]],
+            [[
+                'email' => 0, // email
+            ]],
+            [[
+                'email' => 'sdfsdf@', // email
+            ]],
+        ];
+    }
+}

+ 35 - 0
tests/Feature/Models/UserModelTest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Tests\Feature\Models;
+
+use App\Models\User;
+use Tests\FeatureTestCase;
+
+/**
+ * @covers \App\Models\User
+ */
+class UserModelTest extends FeatureTestCase
+{
+    /**
+     * @test
+     */
+    public function test_admin_scope_returns_only_admin()
+    {
+        User::factory()->count(4)->create();
+
+        $firstAdmin = User::factory()->administrator()->create([
+            'name' => 'first',
+        ]);
+        $secondAdmin = User::factory()->administrator()->create([
+            'name' => 'secondAdmin',
+        ]);
+
+        $admins = User::admins()->get();
+
+        $this->assertCount(2, $admins);
+        $this->assertEquals($admins[0]->is_admin, true);
+        $this->assertEquals($admins[1]->is_admin, true);
+        $this->assertEquals($admins[0]->name, $firstAdmin->name);
+        $this->assertEquals($admins[1]->name, $secondAdmin->name);
+    }
+}

+ 25 - 0
tests/Unit/Exceptions/HandlerTest.php

@@ -174,4 +174,29 @@ class HandlerTest extends TestCase
                 'message',
             ]);
     }
+
+    /**
+     * @test
+     */
+    public function test_AccessDeniedException_returns_forbidden_json_response()
+    {
+        $request  = $this->createMock(Request::class);
+        $instance = new Handler($this->createMock(Container::class));
+        $class    = new \ReflectionClass(Handler::class);
+
+        $method = $class->getMethod('render');
+        $method->setAccessible(true);
+
+        $mockException = $this->createMock(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class);
+
+        $response = $method->invokeArgs($instance, [$request, $mockException]);
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+
+        $response = \Illuminate\Testing\TestResponse::fromBaseResponse($response);
+        $response->assertStatus(403)
+            ->assertJsonStructure([
+                'message',
+            ]);
+    }
 }

+ 94 - 0
tests/Unit/HelpersTest.php

@@ -135,4 +135,98 @@ class HelpersTest extends TestCase
             ],
         ];
     }
+
+    /**
+     * @test
+     *
+     * @dataProvider  commaSeparatedToArrayProvider
+     */
+    public function test_commaSeparatedToArray_returns_ids_in_array($str, $expected)
+    {
+        $array = Helpers::commaSeparatedToArray($str);
+
+        $this->assertEquals($expected, $array);
+    }
+
+    /**
+     * Provide data for cleanVersionNumber() tests
+     */
+    public function commaSeparatedToArrayProvider()
+    {
+        return [
+            'NOMINAL' => [
+                '1,2,3',
+                [1, 2, 3],
+            ],
+            'DUPLICATE' => [
+                '1,2,2,3',
+                [1, 2, 2, 3],
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider  invalidCommaSeparatedToArrayProvider
+     */
+    public function test_commaSeparatedToArray_returns_unchanged_ids($str, $expected)
+    {
+        $array = Helpers::commaSeparatedToArray($str);
+
+        $this->assertEquals($expected, $array);
+    }
+
+    /**
+     * Provide data for cleanVersionNumber() tests
+     */
+    public function invalidCommaSeparatedToArrayProvider()
+    {
+        return [
+            'INVALID_IDS_LEADING_SPACES' => [
+                '1, 2,3',
+                '1, 2,3',
+            ],
+            'INVALID_IDS_TRAILING_SPACES' => [
+                '1,2 ,3',
+                '1,2 ,3',
+            ],
+            'INVALID_IDS_BAD_SEPARATOR' => [
+                '1/2/3',
+                '1/2/3',
+            ],
+            'INVALID_IDS_NOT_DIGIT' => [
+                'a,b,c',
+                'a,b,c',
+            ],
+            'INVALID_IDS_MISSING_DIGIT' => [
+                '1,,3',
+                '1,,3',
+            ],
+            'INVALID_IDS_LEADING_COMMA' => [
+                ',2,3',
+                ',2,3',
+            ],
+            'INVALID_IDS_TRAILING_COMMA' => [
+                '1,2,',
+                '1,2,',
+            ],
+            'NOT_STRING_BOOLEAN' => [
+                true,
+                true,
+            ],
+            'NOT_STRING_INT' => [
+                1,
+                1,
+            ],
+            'NOT_STRING_ARRAY' => [
+                [1],
+                [1],
+            ],
+            'NOT_STRING_NULL' => [
+                null,
+                null,
+            ],
+        ];
+    }
 }

+ 80 - 1
tests/Unit/MigratorTest.php

@@ -13,6 +13,7 @@ use App\Services\Migrators\GoogleAuthMigrator;
 use App\Services\Migrators\Migrator;
 use App\Services\Migrators\PlainTextMigrator;
 use App\Services\Migrators\TwoFASMigrator;
+use App\Services\Migrators\TwoFAuthMigrator;
 use App\Services\SettingService;
 use Illuminate\Support\Facades\Storage;
 use Mockery;
@@ -30,6 +31,7 @@ use Tests\TestCase;
  * @covers \App\Services\Migrators\TwoFASMigrator
  * @covers \App\Services\Migrators\PlainTextMigrator
  * @covers \App\Services\Migrators\GoogleAuthMigrator
+ * @covers \App\Services\Migrators\TwoFAuthMigrator
  *
  * @uses \App\Models\TwoFAccount
  */
@@ -208,6 +210,12 @@ class MigratorTest extends TestCase
                 'gauth',
                 $hasSteam = false,
             ],
+            '2FAUTH_MIGRATION_PAYLOAD' => [
+                new TwoFAuthMigrator(),
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
+                'custom',
+                $hasSteam = true,
+            ],
         ];
     }
 
@@ -273,6 +281,10 @@ class MigratorTest extends TestCase
                 new GoogleAuthMigrator(),
                 MigrationTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
             ],
+            'INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD' => [
+                new TwoFAuthMigrator(),
+                MigrationTestData::INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
+            ],
 
         ];
     }
@@ -313,6 +325,10 @@ class MigratorTest extends TestCase
                 new TwoFASMigrator(),
                 MigrationTestData::VALID_2FAS_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE,
             ],
+            'VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE' => [
+                new TwoFAuthMigrator(),
+                MigrationTestData::VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE,
+            ],
         ];
     }
 
@@ -394,12 +410,71 @@ class MigratorTest extends TestCase
         Storage::disk('icons')->assertDirectoryEmpty('/');
     }
 
+    /**
+     * @test
+     *
+     * @dataProvider TwoFAuthWithIconMigrationProvider
+     */
+    public function test_migrate_2fauth_payload_with_icon_sets_and_stores_the_icon($migration)
+    {
+        Storage::fake('icons');
+
+        $migrator = new TwoFAuthMigrator();
+        $accounts = $migrator->migrate($migration);
+
+        $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
+        $this->assertCount(1, $accounts);
+
+        Storage::disk('icons')->assertExists($accounts->first()->icon);
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function TwoFAuthWithIconMigrationProvider()
+    {
+        return [
+            'SVG' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON,
+            ],
+            'PNG' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON,
+            ],
+            'JPG' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON,
+            ],
+            'BMP' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_BMP_ICON,
+            ],
+            'WEBP' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_WEBP_ICON,
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     */
+    public function test_migrate_2fauth_payload_with_unsupported_icon_does_not_fail()
+    {
+        Storage::fake('icons');
+
+        $migrator = new TwoFAuthMigrator();
+        $accounts = $migrator->migrate(MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON);
+
+        $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
+        $this->assertCount(1, $accounts);
+
+        $this->assertNull($this->fakeTwofaccount->icon);
+        Storage::disk('icons')->assertDirectoryEmpty('/');
+    }
+
     /**
      * @test
      *
      * @dataProvider factoryProvider
      */
-    public function test_factory_returns_plain_text_migrator($payload, $migratorClass)
+    public function test_factory_returns_relevant_migrator($payload, $migratorClass)
     {
         $factory = new MigratorFactory();
 
@@ -434,6 +509,10 @@ class MigratorTest extends TestCase
                 MigrationTestData::GOOGLE_AUTH_MIGRATION_URI,
                 GoogleAuthMigrator::class,
             ],
+            '2FAUTH_MIGRATION_URI' => [
+                MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
+                TwoFAuthMigrator::class,
+            ],
         ];
     }