浏览代码

Update tests & minor fixes

Bubka 2 年之前
父节点
当前提交
05a39b6501
共有 61 个文件被更改,包括 2671 次插入526 次删除
  1. 2 0
      app/Api/v1/Requests/TwoFAccountDynamicRequest.php
  2. 2 0
      app/Api/v1/Requests/TwoFAccountStoreRequest.php
  3. 2 0
      app/Api/v1/Requests/TwoFAccountUpdateRequest.php
  4. 2 0
      app/Api/v1/Requests/TwoFAccountUriRequest.php
  5. 1 1
      app/Extensions/WebauthnCredentialBroker.php
  6. 2 1
      app/Helpers/Helpers.php
  7. 3 2
      app/Http/Controllers/Auth/WebAuthnLoginController.php
  8. 1 1
      app/Http/Controllers/Auth/WebAuthnRegisterController.php
  9. 3 0
      app/Http/Middleware/TrustHosts.php
  10. 20 11
      app/Models/TwoFAccount.php
  11. 1 0
      app/Providers/TwoFAuthServiceProvider.php
  12. 1 1
      app/Services/Migrators/GoogleAuthMigrator.php
  13. 1 1
      app/Services/Migrators/PlainTextMigrator.php
  14. 6 2
      app/Services/Migrators/TwoFASMigrator.php
  15. 11 8
      app/Services/ReleaseRadarService.php
  16. 2 0
      config/webauthn.php
  17. 5 1
      tests/Api/v1/Controllers/Auth/UserControllerTest.php
  18. 1 1
      tests/Api/v1/Controllers/GroupControllerTest.php
  19. 25 0
      tests/Api/v1/Controllers/IconControllerTest.php
  20. 25 42
      tests/Api/v1/Controllers/TwoFAccountControllerTest.php
  21. 9 6
      tests/Api/v1/Requests/GroupAssignRequestTest.php
  22. 9 6
      tests/Api/v1/Requests/GroupStoreRequestTest.php
  23. 9 6
      tests/Api/v1/Requests/QrCodeDecodeRequestTest.php
  24. 9 6
      tests/Api/v1/Requests/SettingStoreRequestTest.php
  25. 9 6
      tests/Api/v1/Requests/SettingUpdateRequestTest.php
  26. 9 6
      tests/Api/v1/Requests/TwoFAccountBatchRequestTest.php
  27. 5 2
      tests/Api/v1/Requests/TwoFAccountDynamicRequestTest.php
  28. 9 6
      tests/Api/v1/Requests/TwoFAccountImportRequestTest.php
  29. 9 6
      tests/Api/v1/Requests/TwoFAccountReorderRequestTest.php
  30. 10 6
      tests/Api/v1/Requests/TwoFAccountStoreRequestTest.php
  31. 10 6
      tests/Api/v1/Requests/TwoFAccountUpdateRequestTest.php
  32. 9 6
      tests/Api/v1/Requests/TwoFAccountUriRequestTest.php
  33. 5 3
      tests/Classes/LocalFileFactory.php
  34. 0 257
      tests/Classes/OtpTestData.php
  35. 161 0
      tests/Data/HttpRequestTestData.php
  36. 364 0
      tests/Data/MigrationTestData.php
  37. 116 0
      tests/Data/OtpTestData.php
  38. 26 3
      tests/Feature/Http/Auth/ForgotPasswordControllerTest.php
  39. 51 18
      tests/Feature/Http/Auth/LoginTest.php
  40. 4 1
      tests/Feature/Http/Auth/PasswordControllerTest.php
  41. 15 10
      tests/Feature/Http/Auth/RegisterControllerTest.php
  42. 7 3
      tests/Feature/Http/Auth/ResetPasswordControllerTest.php
  43. 5 1
      tests/Feature/Http/Auth/UserControllerTest.php
  44. 154 7
      tests/Feature/Http/Auth/WebAuthnDeviceLostControllerTest.php
  45. 141 26
      tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php
  46. 6 1
      tests/Feature/Http/Auth/WebAuthnManageControllerTest.php
  47. 71 10
      tests/Feature/Http/Auth/WebAuthnRecoveryControllerTest.php
  48. 79 0
      tests/Feature/Http/Auth/WebAuthnRegisterControllerTest.php
  49. 122 0
      tests/Feature/Http/SystemControllerTest.php
  50. 204 4
      tests/Feature/Models/TwoFAccountModelTest.php
  51. 2 1
      tests/Feature/Services/GroupServiceTest.php
  52. 83 16
      tests/Feature/Services/LogoServiceTest.php
  53. 2 1
      tests/Feature/Services/QrCodeServiceTest.php
  54. 138 0
      tests/Feature/Services/ReleaseRadarServiceTest.php
  55. 25 2
      tests/Feature/Services/SettingServiceTest.php
  56. 7 5
      tests/Feature/Services/TwoFAccountServiceTest.php
  57. 48 10
      tests/Unit/Exceptions/HandlerTest.php
  58. 98 0
      tests/Unit/HelpersTest.php
  59. 6 0
      tests/Unit/Listeners/CleanIconStorageTest.php
  60. 25 7
      tests/Unit/Listeners/DissociateTwofaccountFromGroupTest.php
  61. 484 0
      tests/Unit/MigratorTest.php

+ 2 - 0
app/Api/v1/Requests/TwoFAccountDynamicRequest.php

@@ -35,6 +35,8 @@ class TwoFAccountDynamicRequest extends FormRequest
     /**
      * Prepare the data for validation.
      *
+     * @codeCoverageIgnore
+     *
      * @return void
      */
     protected function prepareForValidation()

+ 2 - 0
app/Api/v1/Requests/TwoFAccountStoreRequest.php

@@ -40,6 +40,8 @@ class TwoFAccountStoreRequest extends FormRequest
     /**
      * Prepare the data for validation.
      *
+     * @codeCoverageIgnore
+     *
      * @return void
      */
     protected function prepareForValidation()

+ 2 - 0
app/Api/v1/Requests/TwoFAccountUpdateRequest.php

@@ -40,6 +40,8 @@ class TwoFAccountUpdateRequest extends FormRequest
     /**
      * Prepare the data for validation.
      *
+     * @codeCoverageIgnore
+     *
      * @return void
      */
     protected function prepareForValidation()

+ 2 - 0
app/Api/v1/Requests/TwoFAccountUriRequest.php

@@ -33,6 +33,8 @@ class TwoFAccountUriRequest extends FormRequest
     /**
      * Prepare the data for validation.
      *
+     * @codeCoverageIgnore
+     *
      * @return void
      */
     protected function prepareForValidation()

+ 1 - 1
app/Extensions/WebauthnCredentialBroker.php

@@ -31,7 +31,7 @@ class WebauthnCredentialBroker extends PasswordBroker
         $token = $this->tokens->create($user);
 
         if ($callback) {
-            $callback($user, $token);
+            $callback($user, $token); // @codeCoverageIgnore
         } else {
             $user->sendWebauthnRecoveryNotification($token);
         }

+ 2 - 1
app/Helpers/Helpers.php

@@ -25,6 +25,7 @@ class Helpers
      */
     public static function cleanVersionNumber(?string $release) : string|false
     {
-        return preg_match('/([[0-9][0-9\.]*[0-9])/', $release, $version) ? $version[0] : false;
+        // We use the regex for semver detection (see https://semver.org/)
+        return preg_match('/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/', $release, $version) ? $version[0] : false;
     }
 }

+ 3 - 2
app/Http/Controllers/Auth/WebAuthnLoginController.php

@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Log;
 use Laragear\WebAuthn\Http\Requests\AssertedRequest;
 use Laragear\WebAuthn\Http\Requests\AssertionRequest;
 use Laragear\WebAuthn\WebAuthn;
+use Illuminate\Support\Arr;
 
 class WebAuthnLoginController extends Controller
 {
@@ -33,7 +34,7 @@ class WebAuthnLoginController extends Controller
      */
     public function options(AssertionRequest $request) : Responsable|JsonResponse
     {
-        switch (env('WEBAUTHN_USER_VERIFICATION')) {
+        switch (config('webauthn.user_verification')) {
             case WebAuthn::USER_VERIFICATION_DISCOURAGED:
                 $request = $request->fastLogin();    // Makes the authenticator to only check for user presence on registration
                 break;
@@ -69,7 +70,7 @@ class WebAuthnLoginController extends Controller
 
             // Some authenticators do not send a userHandle so we hack the response to be compliant
             // with Larapass/webauthn-lib implementation that waits for a userHandle
-            if (! $response['userHandle']) {
+            if (!Arr::exists($response, 'userHandle') || blank($response['userHandle'])) {
                 $response['userHandle'] = User::getFromCredentialId($request->id)?->userHandle();
                 $request->merge(['response' => $response]);
             }

+ 1 - 1
app/Http/Controllers/Auth/WebAuthnRegisterController.php

@@ -19,7 +19,7 @@ class WebAuthnRegisterController extends Controller
      */
     public function options(AttestationRequest $request) : Responsable
     {
-        switch (env('WEBAUTHN_USER_VERIFICATION')) {
+        switch (config('webauthn.user_verification')) {
             case WebAuthn::USER_VERIFICATION_DISCOURAGED:
                 $request = $request->fastRegistration();    // Makes the authenticator to only check for user presence on registration
                 break;

+ 3 - 0
app/Http/Middleware/TrustHosts.php

@@ -4,6 +4,9 @@ namespace App\Http\Middleware;
 
 use Illuminate\Http\Middleware\TrustHosts as Middleware;
 
+/**
+ * @codeCoverageIgnore
+ */
 class TrustHosts extends Middleware
 {
     /**

+ 20 - 11
app/Models/TwoFAccount.php

@@ -60,8 +60,6 @@ class TwoFAccount extends Model implements Sortable
 
     const FAKE_ID = -2;
 
-    private const IMAGELINK_STORAGE_PATH = 'imagesLink/';
-
     /**
      * List of OTP types supported by 2FAuth
      */
@@ -376,10 +374,6 @@ class TwoFAccount extends Model implements Sortable
             $this->enforceAsSteam();
         }
 
-        if (! $this->icon && $skipIconFetching) {
-            $this->icon = $this->getDefaultIcon();
-        }
-
         if (! $this->icon && Settings::get('getOfficialIcons') && ! $skipIconFetching) {
             $this->icon = $this->getDefaultIcon();
         }
@@ -441,6 +435,22 @@ class TwoFAccount extends Model implements Sortable
         return $this;
     }
 
+    /**
+     * Compare 2 TwoFAccounts
+     */
+    public function equals(self $other): bool
+    {
+        return $this->service === $other->service &&
+            $this->account === $other->account &&
+            $this->icon === $other->icon &&
+            $this->otp_type === $other->otp_type &&
+            $this->secret === $other->secret &&
+            $this->digits === $other->digits &&
+            $this->algorithm === $other->algorithm &&
+            $this->period === $other->period &&
+            $this->counter === $other->counter;
+    }
+
     /**
      * Sets model attributes to STEAM values
      */
@@ -534,7 +544,6 @@ class TwoFAccount extends Model implements Sortable
         try {
             $path_parts  = pathinfo($url);
             $newFilename = Helpers::getUniqueFilename($path_parts['extension']);
-            $imageFile   = self::IMAGELINK_STORAGE_PATH . $newFilename;
 
             try {
                 $response = Http::retry(3, 100)->get($url);
@@ -546,8 +555,10 @@ class TwoFAccount extends Model implements Sortable
                 Log::error(sprintf('Cannot fetch imageLink at "%s"', $url));
             }
 
-            if (in_array(Storage::mimeType($imageFile), ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'])
-                && getimagesize(storage_path() . '/app/' . $imageFile)) {
+            if (
+                in_array(Storage::disk('imagesLink')->mimeType($newFilename), ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'])
+                && getimagesize(Storage::disk('imagesLink')->path($newFilename))
+            ) {
                 // Should be a valid image, we move it to the icons disk
                 if (Storage::disk('icons')->put($newFilename, Storage::disk('imagesLink')->get($newFilename))) {
                     Storage::disk('imagesLink')->delete($newFilename);
@@ -555,10 +566,8 @@ class TwoFAccount extends Model implements Sortable
 
                 Log::info(sprintf('Icon file %s stored', $newFilename));
             } else {
-                // @codeCoverageIgnoreStart
                 Storage::disk('imagesLink')->delete($newFilename);
                 throw new \Exception('Unsupported mimeType or missing image on storage');
-                // @codeCoverageIgnoreEnd
             }
 
             return $newFilename;

+ 1 - 0
app/Providers/TwoFAuthServiceProvider.php

@@ -49,6 +49,7 @@ class TwoFAuthServiceProvider extends ServiceProvider implements DeferrableProvi
     /**
      * Get the services provided by the provider.
      *
+     * @codeCoverageIgnore
      * @return array
      */
     public function provides()

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

@@ -59,7 +59,7 @@ class GoogleAuthMigrator extends Migrator
 
                 // The token failed to generate a valid account so we create a fake account to be returned.
                 $fakeAccount           = new TwoFAccount();
-                $fakeAccount->id       = -2;
+                $fakeAccount->id       = TwoFAccount::FAKE_ID;
                 $fakeAccount->otp_type = $fakeAccount::TOTP;
                 // Only basic fields are filled to limit the risk of another exception.
                 $fakeAccount->account = $otp_parameters->getName() ?? __('twofaccounts.import.invalid_account');

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

@@ -39,7 +39,7 @@ class PlainTextMigrator extends Migrator
 
                 // The token failed to generate a valid account so we create a fake account to be returned.
                 $fakeAccount           = new TwoFAccount();
-                $fakeAccount->id       = -2;
+                $fakeAccount->id       = TwoFAccount::FAKE_ID;
                 $fakeAccount->otp_type = substr($uri, 10, 4);
                 // Only basic fields are filled to limit the risk of another exception.
                 $fakeAccount->account = __('twofaccounts.import.invalid_account');

+ 6 - 2
app/Services/Migrators/TwoFASMigrator.php

@@ -89,8 +89,12 @@ class TwoFASMigrator extends Migrator
             $parameters['secret']    = $this->padToValidBase32Secret($otp_parameters['secret']);
             $parameters['algorithm'] = $otp_parameters['otp']['algorithm'];
             $parameters['digits']    = $otp_parameters['otp']['digits'];
-            $parameters['counter']   = $otp_parameters['otp']['counter'] ?? null;
-            $parameters['period']    = $otp_parameters['otp']['period'] ?? null;
+            $parameters['counter']   = strtolower($parameters['otp_type']) === 'hotp' && $otp_parameters['otp']['counter'] > 0
+                ? $otp_parameters['otp']['counter']
+                : null;
+            $parameters['period']    = strtolower($parameters['otp_type']) === 'totp' && $otp_parameters['otp']['period'] > 0
+                ? $otp_parameters['otp']['period']
+                : null;
 
             try {
                 $twofaccounts[$key] = new TwoFAccount;

+ 11 - 8
app/Services/ReleaseRadarService.php

@@ -16,7 +16,7 @@ class ReleaseRadarService
      */
     public function scheduledScan() : void
     {
-        if ((Settings::get('lastRadarScan') + 604800) < time()) {
+        if ((Settings::get('lastRadarScan') + (60 * 60 * 24 * 7)) < time()) {
             $this->newRelease();
         }
     }
@@ -39,18 +39,19 @@ class ReleaseRadarService
     protected function newRelease() : false|string
     {
         if ($latestReleaseData = json_decode($this->getLatestReleaseData())) {
+
             $githubVersion    = Helpers::cleanVersionNumber($latestReleaseData->tag_name);
             $installedVersion = Helpers::cleanVersionNumber(config('2fauth.version'));
 
-            if ($githubVersion > $installedVersion && $latestReleaseData->prerelease == false && $latestReleaseData->draft == false) {
-                Settings::set('latestRelease', $latestReleaseData->tag_name);
+            if ($githubVersion && $installedVersion) {
+                if ($githubVersion > $installedVersion && $latestReleaseData->prerelease == false && $latestReleaseData->draft == false) {
+                    Settings::set('latestRelease', $latestReleaseData->tag_name);
 
-                return $latestReleaseData->tag_name;
-            } else {
-                Settings::delete('latestRelease');
+                    return $latestReleaseData->tag_name;
+                } else {
+                    Settings::delete('latestRelease');
+                }
             }
-
-            Settings::set('lastRadarScan', time());
         }
 
         return false;
@@ -68,6 +69,8 @@ class ReleaseRadarService
                 ->get(config('2fauth.latestReleaseUrl'));
 
             if ($response->successful()) {
+                Settings::set('lastRadarScan', time());
+
                 return $response->body();
             }
         } catch (\Exception $exception) {

+ 2 - 0
config/webauthn.php

@@ -2,6 +2,8 @@
 
 return [
 
+    'user_verification' => env('WEBAUTHN_USER_VERIFICATION', 'discouraged'),
+
     /*
     |--------------------------------------------------------------------------
     | Relaying Party

+ 5 - 1
tests/Api/v1/Controllers/Auth/UserControllerTest.php

@@ -5,6 +5,10 @@ namespace Tests\Api\v1\Controllers\Auth;
 use App\Models\User;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers \App\Api\v1\Controllers\UserController
+ * @covers \App\Api\v1\Resources\UserResource
+ */
 class UserControllerTest extends FeatureTestCase
 {
     /**
@@ -15,7 +19,7 @@ class UserControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 1 - 1
tests/Api/v1/Controllers/GroupControllerTest.php

@@ -21,7 +21,7 @@ class GroupControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 25 - 0
tests/Api/v1/Controllers/IconControllerTest.php

@@ -40,6 +40,31 @@ class IconControllerTest extends FeatureTestCase
             ->assertStatus(422);
     }
 
+    /**
+     * @test
+     */
+    public function test_fetch_logo_returns_filename()
+    {
+        $response = $this->json('POST', '/api/v1/icons/default', [
+            'service' => 'twitter',
+        ])
+            ->assertStatus(201)
+            ->assertJsonStructure([
+                'filename',
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_fetch_unknown_logo_returns_nothing()
+    {
+        $response = $this->json('POST', '/api/v1/icons/default', [
+            'service' => 'unknown_company',
+        ])
+            ->assertNoContent();
+    }
+
     /**
      * @test
      */

+ 25 - 42
tests/Api/v1/Controllers/TwoFAccountControllerTest.php

@@ -9,13 +9,16 @@ use App\Models\User;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 use Tests\Classes\LocalFile;
-use Tests\Classes\OtpTestData;
+use Tests\Data\OtpTestData;
 use Tests\FeatureTestCase;
+use Tests\Data\MigrationTestData;
 
 /**
  * @covers \App\Api\v1\Controllers\TwoFAccountController
  * @covers \App\Api\v1\Resources\TwoFAccountReadResource
  * @covers \App\Api\v1\Resources\TwoFAccountStoreResource
+ * @covers \App\Providers\MigrationServiceProvider
+ * @covers \App\Providers\TwoFAuthServiceProvider
  */
 class TwoFAccountControllerTest extends FeatureTestCase
 {
@@ -122,7 +125,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -447,7 +450,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/migration', [
-                'payload'    => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
+                'payload'    => MigrationTestData::GOOGLE_AUTH_MIGRATION_URI,
                 'withSecret' => 1,
             ])
             ->assertOk()
@@ -483,7 +486,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/migration', [
-                'uri' => OtpTestData::INVALID_GOOGLE_AUTH_MIGRATION_URI,
+                'uri' => MigrationTestData::INVALID_GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertStatus(422);
     }
@@ -507,7 +510,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/migration', [
-                'payload' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI,
+                'payload' => MigrationTestData::GOOGLE_AUTH_MIGRATION_URI,
             ])
             ->assertOk()
             ->assertJsonFragment([
@@ -524,7 +527,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
     {
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/migration', [
-                'payload' => OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
+                'payload' => MigrationTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
             ])
             ->assertStatus(400)
             ->assertJsonStructure([
@@ -546,22 +549,11 @@ class TwoFAccountControllerTest extends FeatureTestCase
                 'withSecret' => 1,
             ])
             ->assertOk()
-            ->assertJsonCount(5, $key = null)
-            ->assertJsonFragment([
-                'id'        => 0,
-                'service'   => OtpTestData::SERVICE . '_totp',
-                'account'   => OtpTestData::ACCOUNT . '_totp',
-                'otp_type'  => 'totp',
-                'secret'    => OtpTestData::SECRET,
-                'digits'    => OtpTestData::DIGITS_DEFAULT,
-                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
-                'period'    => OtpTestData::PERIOD_DEFAULT,
-                'counter'   => null,
-            ])
+            ->assertJsonCount(3, $key = null)
             ->assertJsonFragment([
                 'id'        => 0,
-                'service'   => OtpTestData::SERVICE . '_totp_custom',
-                'account'   => OtpTestData::ACCOUNT . '_totp_custom',
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
                 'otp_type'  => 'totp',
                 'secret'    => OtpTestData::SECRET,
                 'digits'    => OtpTestData::DIGITS_CUSTOM,
@@ -571,21 +563,10 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ])
             ->assertJsonFragment([
                 'id'        => 0,
-                'service'   => OtpTestData::SERVICE . '_hotp',
-                'account'   => OtpTestData::ACCOUNT . '_hotp',
+                'service'   => OtpTestData::SERVICE,
+                'account'   => OtpTestData::ACCOUNT,
                 'otp_type'  => 'hotp',
                 'secret'    => OtpTestData::SECRET,
-                'digits'    => OtpTestData::DIGITS_DEFAULT,
-                'algorithm' => OtpTestData::ALGORITHM_DEFAULT,
-                'period'    => null,
-                'counter'   => OtpTestData::COUNTER_DEFAULT,
-            ])
-            ->assertJsonFragment([
-                'id'        => 0,
-                'service'   => OtpTestData::SERVICE . '_hotp_custom',
-                'account'   => OtpTestData::ACCOUNT . '_hotp_custom',
-                'otp_type'  => 'totp',
-                'secret'    => OtpTestData::SECRET,
                 'digits'    => OtpTestData::DIGITS_CUSTOM,
                 'algorithm' => OtpTestData::ALGORITHM_CUSTOM,
                 'period'    => null,
@@ -594,7 +575,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
             ->assertJsonFragment([
                 'id'        => 0,
                 'service'   => OtpTestData::STEAM,
-                'account'   => OtpTestData::ACCOUNT . '_steam',
+                'account'   => OtpTestData::ACCOUNT,
                 'otp_type'  => 'steamtotp',
                 'secret'    => OtpTestData::STEAM_SECRET,
                 'digits'    => OtpTestData::DIGITS_STEAM,
@@ -625,10 +606,10 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function invalidAegisJsonFileProvider()
     {
         return [
-            'validPlainTextFile' => [
+            'encryptedAegisJsonFile' => [
                 LocalFile::fake()->encryptedAegisJsonFile(),
             ],
-            'validPlainTextFileWithNewLines' => [
+            'invalidAegisJsonFile' => [
                 LocalFile::fake()->invalidAegisJsonFile(),
             ],
         ];
@@ -720,16 +701,16 @@ class TwoFAccountControllerTest extends FeatureTestCase
     public function invalidPlainTextFileProvider()
     {
         return [
-            'validPlainTextFile' => [
+            'invalidPlainTextFileEmpty' => [
                 LocalFile::fake()->invalidPlainTextFileEmpty(),
             ],
-            'validPlainTextFileWithNewLines' => [
+            'invalidPlainTextFileNoUri' => [
                 LocalFile::fake()->invalidPlainTextFileNoUri(),
             ],
-            'validPlainTextFileWithNewLines' => [
+            'invalidPlainTextFileWithInvalidUri' => [
                 LocalFile::fake()->invalidPlainTextFileWithInvalidUri(),
             ],
-            'validPlainTextFileWithNewLines' => [
+            'invalidPlainTextFileWithInvalidLine' => [
                 LocalFile::fake()->invalidPlainTextFileWithInvalidLine(),
             ],
         ];
@@ -744,7 +725,8 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/reorder', [
-                'orderedIds' => [3, 2, 1], ])
+                'orderedIds' => [3, 2, 1],
+            ])
             ->assertStatus(200)
             ->assertJsonStructure([
                 'message',
@@ -760,7 +742,8 @@ class TwoFAccountControllerTest extends FeatureTestCase
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('POST', '/api/v1/twofaccounts/reorder', [
-                'orderedIds' => '3,2,1', ])
+                'orderedIds' => '3,2,1',
+            ])
             ->assertStatus(422);
     }
 

+ 9 - 6
tests/Api/v1/Requests/GroupAssignRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\GroupAssignRequest
+ */
 class GroupAssignRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class GroupAssignRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new GroupAssignRequest();
 
@@ -29,7 +32,7 @@ class GroupAssignRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new GroupAssignRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class GroupAssignRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -54,7 +57,7 @@ class GroupAssignRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new GroupAssignRequest();
         $validator = Validator::make($data, $request->rules());
@@ -65,7 +68,7 @@ class GroupAssignRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/GroupStoreRequestTest.php

@@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\GroupStoreRequest
+ */
 class GroupStoreRequestTest extends FeatureTestCase
 {
     use WithoutMiddleware;
@@ -21,8 +24,8 @@ class GroupStoreRequestTest extends FeatureTestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new GroupStoreRequest();
 
@@ -32,7 +35,7 @@ class GroupStoreRequestTest extends FeatureTestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new GroupStoreRequest();
         $validator = Validator::make($data, $request->rules());
@@ -43,7 +46,7 @@ class GroupStoreRequestTest extends FeatureTestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -55,7 +58,7 @@ class GroupStoreRequestTest extends FeatureTestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $group = new Group([
             'name' => $this->uniqueGroupName,
@@ -72,7 +75,7 @@ class GroupStoreRequestTest extends FeatureTestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/QrCodeDecodeRequestTest.php

@@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Validator;
 use Tests\Classes\LocalFile;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\QrCodeDecodeRequest
+ */
 class QrCodeDecodeRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -19,8 +22,8 @@ class QrCodeDecodeRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new QrCodeDecodeRequest();
 
@@ -30,7 +33,7 @@ class QrCodeDecodeRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new QrCodeDecodeRequest();
         $validator = Validator::make($data, $request->rules());
@@ -41,7 +44,7 @@ class QrCodeDecodeRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         $file = LocalFile::fake()->validQrcode();
 
@@ -55,7 +58,7 @@ class QrCodeDecodeRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new QrCodeDecodeRequest();
         $validator = Validator::make($data, $request->rules());
@@ -66,7 +69,7 @@ class QrCodeDecodeRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/SettingStoreRequestTest.php

@@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\SettingStoreRequest
+ */
 class SettingStoreRequestTest extends FeatureTestCase
 {
     use WithoutMiddleware;
@@ -21,8 +24,8 @@ class SettingStoreRequestTest extends FeatureTestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new SettingStoreRequest();
 
@@ -32,7 +35,7 @@ class SettingStoreRequestTest extends FeatureTestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new SettingStoreRequest();
         $validator = Validator::make($data, $request->rules());
@@ -43,7 +46,7 @@ class SettingStoreRequestTest extends FeatureTestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -64,7 +67,7 @@ class SettingStoreRequestTest extends FeatureTestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         Settings::set($this->uniqueKey, 'uniqueValue');
 
@@ -77,7 +80,7 @@ class SettingStoreRequestTest extends FeatureTestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/SettingUpdateRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\SettingUpdateRequest
+ */
 class SettingUpdateRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class SettingUpdateRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new SettingUpdateRequest();
 
@@ -29,7 +32,7 @@ class SettingUpdateRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new SettingUpdateRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class SettingUpdateRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -58,7 +61,7 @@ class SettingUpdateRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new SettingUpdateRequest();
         $validator = Validator::make($data, $request->rules());
@@ -69,7 +72,7 @@ class SettingUpdateRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/TwoFAccountBatchRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountBatchRequest
+ */
 class TwoFAccountBatchRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class TwoFAccountBatchRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountBatchRequest();
 
@@ -29,7 +32,7 @@ class TwoFAccountBatchRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountBatchRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class TwoFAccountBatchRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -55,7 +58,7 @@ class TwoFAccountBatchRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountBatchRequest();
         $validator = Validator::make($data, $request->rules());
@@ -66,7 +69,7 @@ class TwoFAccountBatchRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 5 - 2
tests/Api/v1/Requests/TwoFAccountDynamicRequestTest.php

@@ -9,6 +9,9 @@ use Illuminate\Foundation\Testing\WithoutMiddleware;
 use Illuminate\Support\Facades\Auth;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountDynamicRequest
+ */
 class TwoFAccountDynamicRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -19,8 +22,8 @@ class TwoFAccountDynamicRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountDynamicRequest();
 

+ 9 - 6
tests/Api/v1/Requests/TwoFAccountImportRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountImportRequest
+ */
 class TwoFAccountImportRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class TwoFAccountImportRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountImportRequest();
 
@@ -29,7 +32,7 @@ class TwoFAccountImportRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountImportRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class TwoFAccountImportRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -52,7 +55,7 @@ class TwoFAccountImportRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountImportRequest();
         $validator = Validator::make($data, $request->rules());
@@ -63,7 +66,7 @@ class TwoFAccountImportRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/TwoFAccountReorderRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountReorderRequest
+ */
 class TwoFAccountReorderRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class TwoFAccountReorderRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountReorderRequest();
 
@@ -29,7 +32,7 @@ class TwoFAccountReorderRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountReorderRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class TwoFAccountReorderRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -55,7 +58,7 @@ class TwoFAccountReorderRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountReorderRequest();
         $validator = Validator::make($data, $request->rules());
@@ -66,7 +69,7 @@ class TwoFAccountReorderRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 10 - 6
tests/Api/v1/Requests/TwoFAccountStoreRequestTest.php

@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountStoreRequest
+ * @covers \App\Rules\IsBase32Encoded
+ */
 class TwoFAccountStoreRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +22,8 @@ class TwoFAccountStoreRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountStoreRequest();
 
@@ -29,7 +33,7 @@ class TwoFAccountStoreRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountStoreRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +44,7 @@ class TwoFAccountStoreRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -98,7 +102,7 @@ class TwoFAccountStoreRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountStoreRequest();
         $validator = Validator::make($data, $request->rules());
@@ -109,7 +113,7 @@ class TwoFAccountStoreRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 10 - 6
tests/Api/v1/Requests/TwoFAccountUpdateRequestTest.php

@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountUpdateRequest
+ * @covers \App\Rules\IsBase32Encoded
+ */
 class TwoFAccountUpdateRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +22,8 @@ class TwoFAccountUpdateRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountUpdateRequest();
 
@@ -29,7 +33,7 @@ class TwoFAccountUpdateRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountUpdateRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +44,7 @@ class TwoFAccountUpdateRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -80,7 +84,7 @@ class TwoFAccountUpdateRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountUpdateRequest();
         $validator = Validator::make($data, $request->rules());
@@ -91,7 +95,7 @@ class TwoFAccountUpdateRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 9 - 6
tests/Api/v1/Requests/TwoFAccountUriRequestTest.php

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Validator;
 use Tests\TestCase;
 
+/**
+ * @covers \App\Api\v1\Requests\TwoFAccountUriRequest
+ */
 class TwoFAccountUriRequestTest extends TestCase
 {
     use WithoutMiddleware;
@@ -18,8 +21,8 @@ class TwoFAccountUriRequestTest extends TestCase
     public function test_user_is_authorized()
     {
         Auth::shouldReceive('check')
-        ->once()
-        ->andReturn(true);
+            ->once()
+            ->andReturn(true);
 
         $request = new TwoFAccountUriRequest();
 
@@ -29,7 +32,7 @@ class TwoFAccountUriRequestTest extends TestCase
     /**
      * @dataProvider provideValidData
      */
-    public function test_valid_data(array $data) : void
+    public function test_valid_data(array $data): void
     {
         $request   = new TwoFAccountUriRequest();
         $validator = Validator::make($data, $request->rules());
@@ -40,7 +43,7 @@ class TwoFAccountUriRequestTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideValidData() : array
+    public function provideValidData(): array
     {
         return [
             [[
@@ -59,7 +62,7 @@ class TwoFAccountUriRequestTest extends TestCase
     /**
      * @dataProvider provideInvalidData
      */
-    public function test_invalid_data(array $data) : void
+    public function test_invalid_data(array $data): void
     {
         $request   = new TwoFAccountUriRequest();
         $validator = Validator::make($data, $request->rules());
@@ -70,7 +73,7 @@ class TwoFAccountUriRequestTest extends TestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideInvalidData() : array
+    public function provideInvalidData(): array
     {
         return [
             [[

+ 5 - 3
tests/Classes/LocalFileFactory.php

@@ -3,6 +3,8 @@
 namespace Tests\Classes;
 
 use Illuminate\Http\Testing\File;
+use Tests\Data\MigrationTestData;
+use Tests\Data\OtpTestData;
 
 class LocalFileFactory
 {
@@ -58,7 +60,7 @@ class LocalFileFactory
         return new File('validAegisMigration.json', tap(tmpfile(), function ($temp) {
             ob_start();
 
-            echo OtpTestData::AEGIS_JSON_MIGRATION_PAYLOAD;
+            echo MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD;
 
             fwrite($temp, ob_get_clean());
         }));
@@ -74,7 +76,7 @@ class LocalFileFactory
         return new File('invalidAegisMigration.json', tap(tmpfile(), function ($temp) {
             ob_start();
 
-            echo OtpTestData::INVALID_AEGIS_JSON_MIGRATION_PAYLOAD;
+            echo MigrationTestData::INVALID_AEGIS_JSON_MIGRATION_PAYLOAD;
 
             fwrite($temp, ob_get_clean());
         }));
@@ -90,7 +92,7 @@ class LocalFileFactory
         return new File('encryptedAegisJsonFile.txt', tap(tmpfile(), function ($temp) {
             ob_start();
 
-            echo OtpTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD;
+            echo MigrationTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD;
 
             fwrite($temp, ob_get_clean());
         }));

+ 0 - 257
tests/Classes/OtpTestData.php

@@ -1,257 +0,0 @@
-<?php
-
-namespace Tests\Classes;
-
-class OtpTestData
-{
-    const ACCOUNT = 'account';
-
-    const SERVICE = 'service';
-
-    const STEAM = 'Steam';
-
-    const SECRET = 'A4GRFHVVRBGY7UIW';
-
-    const STEAM_SECRET = 'XJGTDRUUKZH3X7TQN2QZUGCGXZCC5LXE';
-
-    const ALGORITHM_DEFAULT = 'sha1';
-
-    const ALGORITHM_CUSTOM = 'sha256';
-
-    const DIGITS_DEFAULT = 6;
-
-    const DIGITS_CUSTOM = 7;
-
-    const DIGITS_STEAM = 5;
-
-    const PERIOD_DEFAULT = 30;
-
-    const PERIOD_CUSTOM = 40;
-
-    const COUNTER_DEFAULT = 0;
-
-    const COUNTER_CUSTOM = 5;
-
-    const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
-
-    const ICON = 'test.png';
-
-    const TOTP_FULL_CUSTOM_URI = 'otpauth://totp/' . self::SERVICE . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE . '&digits=' . self::DIGITS_CUSTOM . '&period=' . self::PERIOD_CUSTOM . '&algorithm=' . self::ALGORITHM_CUSTOM . '&image=' . self::IMAGE;
-
-    const HOTP_FULL_CUSTOM_URI = 'otpauth://hotp/' . self::SERVICE . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE . '&digits=' . self::DIGITS_CUSTOM . '&counter=' . self::COUNTER_CUSTOM . '&algorithm=' . self::ALGORITHM_CUSTOM . '&image=' . self::IMAGE;
-
-    const TOTP_SHORT_URI = 'otpauth://totp/' . self::ACCOUNT . '?secret=' . self::SECRET;
-
-    const HOTP_SHORT_URI = 'otpauth://hotp/' . self::ACCOUNT . '?secret=' . self::SECRET;
-
-    const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=https%3A%2F%2Fen.opensuse.org%2Fimage.png';
-
-    const INVALID_OTPAUTH_URI = 'otpauth://Xotp/' . self::ACCOUNT . '?secret=' . self::SECRET;
-
-    const STEAM_TOTP_URI = 'otpauth://totp/' . self::STEAM . ':' . self::ACCOUNT . '?secret=' . self::STEAM_SECRET . '&issuer=' . self::STEAM . '&digits=' . self::DIGITS_STEAM . '&period=30&algorithm=' . self::ALGORITHM_DEFAULT;
-
-    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'totp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => self::PERIOD_CUSTOM,
-        'counter'   => null,
-    ];
-
-    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP = [
-        'account'  => self::ACCOUNT,
-        'otp_type' => 'totp',
-        'secret'   => self::SECRET,
-    ];
-
-    const ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE = [
-        'account'  => self::ACCOUNT,
-        'otp_type' => 'Xotp',
-        'secret'   => self::SECRET,
-    ];
-
-    const ARRAY_OF_INVALID_PARAMETERS_FOR_TOTP = [
-        'account'  => self::ACCOUNT,
-        'otp_type' => 'totp',
-        'secret'   => 0,
-    ];
-
-    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP = [
-        'service'   => self::SERVICE,
-        'account'   => self::ACCOUNT,
-        'icon'      => self::ICON,
-        'otp_type'  => 'hotp',
-        'secret'    => self::SECRET,
-        'digits'    => self::DIGITS_CUSTOM,
-        'algorithm' => self::ALGORITHM_CUSTOM,
-        'period'    => null,
-        'counter'   => self::COUNTER_CUSTOM,
-    ];
-
-    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP = [
-        'account'  => self::ACCOUNT,
-        'otp_type' => 'hotp',
-        'secret'   => self::SECRET,
-    ];
-
-    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_STEAM_TOTP = [
-        'service'   => self::STEAM,
-        'account'   => self::ACCOUNT,
-        'otp_type'  => 'steamtotp',
-        'secret'    => self::STEAM_SECRET,
-        'digits'    => self::DIGITS_STEAM,
-        'algorithm' => self::ALGORITHM_DEFAULT,
-        'period'    => self::PERIOD_DEFAULT,
-        'counter'   => null,
-    ];
-
-    const GOOGLE_AUTH_MIGRATION_URI = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
-
-    const INVALID_GOOGLE_AUTH_MIGRATION_URI = 'otpauthmigration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY2UgASgBMAIKLAoKBw0SnrWITY/RFhILYWNjb3VudF9iaXMaC3NlcnZpY2VfYmlzIAEoATACEAEYASAA';
-
-    const GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA = 'otpauth-migration://offline?data=CiQKCgcNEp61iE2P0RYSB2FjY291bnQaB3NlcnZpY';
-
-    const AEGIS_JSON_MIGRATION_PAYLOAD = '
-    {
-        "version": 1,
-        "header": {
-            "slots": null,
-            "params": null
-        },
-        "db": {
-            "version": 2,
-            "entries": [
-                {
-                    "type": "totp",
-                    "uuid": "5be1b189-260d-4fe1-930a-a78cb669dd86",
-                    "name": "' . self::ACCOUNT . '_totp",
-                    "issuer": "' . self::SERVICE . '_totp",
-                    "note": "",
-                    "icon": null,
-                    "info": {
-                        "secret": "' . self::SECRET . '",
-                        "algo": "' . self::ALGORITHM_DEFAULT . '",
-                        "digits": ' . self::DIGITS_DEFAULT . ',
-                        "period": ' . self::PERIOD_DEFAULT . '
-                    }
-                },
-                {
-                    "type": "totp",
-                    "uuid": "fb2ebd05-9d71-4b2e-9d4e-b7f8d2942bfb",
-                    "name": "' . self::ACCOUNT . '_totp_custom",
-                    "issuer": "' . self::SERVICE . '_totp_custom",
-                    "note": "",
-                    "icon": null,
-                    "info": {
-                        "secret": "' . self::SECRET . '",
-                        "algo": "' . self::ALGORITHM_CUSTOM . '",
-                        "digits": ' . self::DIGITS_CUSTOM . ',
-                        "period": ' . self::PERIOD_CUSTOM . '
-                    }
-                },
-                {
-                    "type": "hotp",
-                    "uuid": "90a2af2e-2857-4515-bb18-52c4fa823f6f",
-                    "name": "' . self::ACCOUNT . '_hotp",
-                    "issuer": "' . self::SERVICE . '_hotp",
-                    "note": "",
-                    "icon": null,
-                    "info": {
-                        "secret": "' . self::SECRET . '",
-                        "algo": "' . self::ALGORITHM_DEFAULT . '",
-                        "digits": ' . self::DIGITS_DEFAULT . ',
-                        "counter": ' . self::COUNTER_DEFAULT . '
-                    }
-                },
-                {
-                    "type": "hotp",
-                    "uuid": "e1b3f683-d5fe-4126-b616-8c8abd8ad97c",
-                    "name": "' . self::ACCOUNT . '_hotp_custom",
-                    "issuer": "' . self::SERVICE . '_hotp_custom",
-                    "note": "",
-                    "icon": null,
-                    "info": {
-                        "secret": "' . self::SECRET . '",
-                        "algo": "' . self::ALGORITHM_CUSTOM . '",
-                        "digits": ' . self::DIGITS_CUSTOM . ',
-                        "counter": ' . self::COUNTER_CUSTOM . '
-                    }
-                },
-                {
-                    "type": "steamtotp",
-                    "uuid": "9fb06143-421d-46e1-a7e9-4aafe44b0e72",
-                    "name": "' . self::ACCOUNT . '_steam",
-                    "issuer": "' . self::STEAM . '",
-                    "note": "",
-                    "icon": "null",
-                    "info": {
-                        "secret": "' . self::STEAM_SECRET . '",
-                        "algo": "' . self::ALGORITHM_DEFAULT . '",
-                        "digits": ' . self::DIGITS_STEAM . ',
-                        "period": ' . self::PERIOD_DEFAULT . '
-                    }
-                }
-            ]
-        }
-    }';
-
-    const INVALID_AEGIS_JSON_MIGRATION_PAYLOAD = '
-    {
-        "version": 1,
-        "header": {
-            "slots": null,
-            "params": null
-        },
-        "db": {
-            "version": 2,
-            "thisIsNotTheCorrectKeyName": [
-                {
-                    "type": "totp",
-                    "uuid": "5be1b189-260d-4fe1-930a-a78cb669dd86",
-                    "name": "' . self::ACCOUNT . '",
-                    "issuer": "' . self::SERVICE . '",
-                    "note": "",
-                    "icon": null,
-                    "info": {
-                        "secret": "' . self::SECRET . '",
-                        "algo": "' . self::ALGORITHM_DEFAULT . '",
-                        "digits": ' . self::DIGITS_DEFAULT . ',
-                        "period": ' . self::PERIOD_DEFAULT . '
-                    }
-                }
-            ]
-        }
-    }';
-
-    const ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD = '
-    {
-        "version": 1,
-        "header": {
-            "slots": [
-                {
-                    "type": 1,
-                    "uuid": "1f447956-c71c-4be4-8192-97197dc67df7",
-                    "key": "d742967686cae462c5732023a72d59245d8q7c5c93a66aeb2q2a350bb8b6a7ae",
-                    "key_params": {
-                        "nonce": "77a8ff6d84265efd2a3ed9b7",
-                        "tag": "cc13fb4a5baz3fd27bc97f5eacaa00d0"
-                    },
-                    "n": 32768,
-                    "r": 8,
-                    "p": 1,
-                    "salt": "1c245b2696b948dt040c30c538aeb6f9620b054d9ff182f33dd4b285b67bed51",
-                    "repaired": true
-                }
-            ],
-            "params": {
-                "nonce": "f31675d9966d2z588bd07788",
-                "tag": "ad5729fa135dc6d6sw87e0c955932661"
-            }
-        },
-        "db": "1rX0ajzsxNbhN2hvnNCMBNooLlzqwz\/LMT3bNEIJjPH+zIvIbA6GVVPHLpna+yvjxLPKVkt1OQig=="
-    }';
-}

+ 161 - 0
tests/Data/HttpRequestTestData.php

@@ -0,0 +1,161 @@
+<?php
+
+namespace Tests\Data;
+
+class HttpRequestTestData
+{
+    const TAG_NAME = 'v3.4.1';
+
+    const NEW_TAG_NAME = 'v3.4.2';
+
+    const SVG_LOGO_BODY = '<svg xmlns="http://www.w3.org/2000/svg" class="r-k200y r-13gxpu9 r-4qtqp9 r-yyyyoo r-np7d94 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr" width="22.706" height="22.706"><path d="M22.706 4.311c-.835.37-1.732.62-2.675.733a4.67 4.67 0 002.048-2.578 9.3 9.3 0 01-2.958 1.13 4.66 4.66 0 00-7.938 4.25 13.229 13.229 0 01-9.602-4.868c-.4.69-.63 1.49-.63 2.342a4.66 4.66 0 002.072 3.878 4.647 4.647 0 01-2.11-.583v.06a4.66 4.66 0 003.737 4.568 4.692 4.692 0 01-2.104.08 4.661 4.661 0 004.352 3.234 9.348 9.348 0 01-5.786 1.995A9.5 9.5 0 010 18.487a13.175 13.175 0 007.14 2.093c8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602a9.47 9.47 0 002.323-2.41z" fill="#1da1f2"/></svg>';
+
+    const TFA_JSON_BODY = '
+    [
+        [
+            "Twitch",
+            {
+                "domain": "twitch.tv",
+                "url": "https://www.twitch.tv/",
+                "tfa":
+                [
+                    "sms",
+                    "custom-software",
+                    "totp"
+                ],
+                "custom-software":
+                [
+                    "Authy"
+                ],
+                "documentation": "https://help.twitch.tv/s/article/two-factor-authentication",
+                "notes": "To activate two factor authentication, you must provide a mobile phone number.",
+                "keywords":
+                [
+                    "entertainment"
+                ]
+            }
+        ],
+        [
+            "Twitter",
+            {
+                "domain": "twitter.com",
+                "tfa":
+                [
+                    "sms",
+                    "totp",
+                    "u2f"
+                ],
+                "documentation": "https://help.twitter.com/en/managing-your-account/two-factor-authentication",
+                "recovery": "https://help.twitter.com/en/managing-your-account/issues-with-login-authentication",
+                "notes": "SMS only available on select providers.",
+                "keywords":
+                [
+                    "social"
+                ]
+            }
+        ],
+        [
+            "Txbit",
+            {
+                "domain": "txbit.io",
+                "tfa":
+                [
+                    "totp"
+                ],
+                "documentation": "https://support.txbit.io/support/solutions/articles/44000447137",
+                "keywords":
+                [
+                    "cryptocurrencies"
+                ]
+            }
+        ]
+    ]';
+
+    const LATEST_RELEASE_BODY_NO_NEW_RELEASE = '
+    {
+        "url": "https://api.github.com/repos/Bubka/2FAuth/releases/84186611",
+        "assets_url": "https://api.github.com/repos/Bubka/2FAuth/releases/84186611/assets",
+        "upload_url": "https://uploads.github.com/repos/Bubka/2FAuth/releases/84186611/assets{?name,label}",
+        "html_url": "https://github.com/Bubka/2FAuth/releases/tag/' . self::TAG_NAME . '",
+        "id": 84186611,
+        "author": {
+            "login": "Bubka",
+            "id": 858858,
+            "node_id": "MDQ6VXNlcjg1ODg1OA==",
+            "avatar_url": "https://avatars.githubusercontent.com/u/858858?v=4",
+            "gravatar_id": "",
+            "url": "https://api.github.com/users/Bubka",
+            "html_url": "https://github.com/Bubka",
+            "followers_url": "https://api.github.com/users/Bubka/followers",
+            "following_url": "https://api.github.com/users/Bubka/following{/other_user}",
+            "gists_url": "https://api.github.com/users/Bubka/gists{/gist_id}",
+            "starred_url": "https://api.github.com/users/Bubka/starred{/owner}{/repo}",
+            "subscriptions_url": "https://api.github.com/users/Bubka/subscriptions",
+            "organizations_url": "https://api.github.com/users/Bubka/orgs",
+            "repos_url": "https://api.github.com/users/Bubka/repos",
+            "events_url": "https://api.github.com/users/Bubka/events{/privacy}",
+            "received_events_url": "https://api.github.com/users/Bubka/received_events",
+            "type": "User",
+            "site_admin": false
+        },
+        "node_id": "RE_kwDOCyNVx84FBJXz",
+        "tag_name": "' . self::TAG_NAME . '",
+        "target_commitish": "master",
+        "name": "' . self::TAG_NAME . '",
+        "draft": false,
+        "prerelease": false,
+        "created_at": "2022-11-25T13:31:45Z",
+        "published_at": "2022-11-25T13:44:10Z",
+        "assets": [
+
+        ],
+        "tarball_url": "https://api.github.com/repos/Bubka/2FAuth/tarball/' . self::TAG_NAME . '",
+        "zipball_url": "https://api.github.com/repos/Bubka/2FAuth/zipball/' . self::TAG_NAME . '",
+        "body": "### Fixed\r\n\r\n- [issue #140](https://github.com/Bubka/2FAuth/issues/140) Bad regex for Period field (advanced form)\r\n- [issue #141](https://github.com/Bubka/2FAuth/issues/141) Digits field is missing in advanced form"
+    }';
+
+    const LATEST_RELEASE_BODY_NEW_RELEASE = '
+    {
+        "url": "https://api.github.com/repos/Bubka/2FAuth/releases/84186611",
+        "assets_url": "https://api.github.com/repos/Bubka/2FAuth/releases/84186611/assets",
+        "upload_url": "https://uploads.github.com/repos/Bubka/2FAuth/releases/84186611/assets{?name,label}",
+        "html_url": "https://github.com/Bubka/2FAuth/releases/tag/' . self::NEW_TAG_NAME . '",
+        "id": 84186611,
+        "author": {
+            "login": "Bubka",
+            "id": 858858,
+            "node_id": "MDQ6VXNlcjg1ODg1OA==",
+            "avatar_url": "https://avatars.githubusercontent.com/u/858858?v=4",
+            "gravatar_id": "",
+            "url": "https://api.github.com/users/Bubka",
+            "html_url": "https://github.com/Bubka",
+            "followers_url": "https://api.github.com/users/Bubka/followers",
+            "following_url": "https://api.github.com/users/Bubka/following{/other_user}",
+            "gists_url": "https://api.github.com/users/Bubka/gists{/gist_id}",
+            "starred_url": "https://api.github.com/users/Bubka/starred{/owner}{/repo}",
+            "subscriptions_url": "https://api.github.com/users/Bubka/subscriptions",
+            "organizations_url": "https://api.github.com/users/Bubka/orgs",
+            "repos_url": "https://api.github.com/users/Bubka/repos",
+            "events_url": "https://api.github.com/users/Bubka/events{/privacy}",
+            "received_events_url": "https://api.github.com/users/Bubka/received_events",
+            "type": "User",
+            "site_admin": false
+        },
+        "node_id": "RE_kwDOCyNVx84FBJXz",
+        "tag_name": "' . self::NEW_TAG_NAME . '",
+        "target_commitish": "master",
+        "name": "' . self::NEW_TAG_NAME . '",
+        "draft": false,
+        "prerelease": false,
+        "created_at": "2022-12-25T13:31:45Z",
+        "published_at": "2022-12-25T13:44:10Z",
+        "assets": [
+
+        ],
+        "tarball_url": "https://api.github.com/repos/Bubka/2FAuth/tarball/' . self::NEW_TAG_NAME . '",
+        "zipball_url": "https://api.github.com/repos/Bubka/2FAuth/zipball/' . self::NEW_TAG_NAME . '",
+        "body": "### Fixed\r\n\r\n- [issue #140](https://github.com/Bubka/2FAuth/issues/140) Bad regex for Period field (advanced form)\r\n- [issue #141](https://github.com/Bubka/2FAuth/issues/141) Digits field is missing in advanced form"
+    }';
+
+    const ICON_PNG = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAsUlEQVR4AWN44aVBEhoCGl4GGLzND/nYW/Fpdsf7urTX8Q74NLwtjf7z+vl/VPDzwvFX4eYIDUhm6//99AGi6PfDOz9OH4Tr+TSrHYuG1/GOn+f3AtGnOV0vvLXeZPr8+/IJouHbthU4nJQfAtQANBuuFJ+GDx2F///9g6gAMn5dOfP34zt8Gr7tWQ838n1DBlDk973r+DS8Sff+snQKBL2KsQOKfJzSAOFC9EPQcEhLAD5LqIU3S31+AAAAAElFTkSuQmCC';
+}

文件差异内容过多而无法显示
+ 364 - 0
tests/Data/MigrationTestData.php


+ 116 - 0
tests/Data/OtpTestData.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Tests\Data;
+
+class OtpTestData
+{
+    const ACCOUNT = 'account';
+
+    const SERVICE = 'service';
+
+    const STEAM = 'Steam';
+
+    const SECRET = 'A4GRFHVVRBGY7UIW';
+
+    const STEAM_SECRET = 'XJGTDRUUKZH3X7TQN2QZUGCGXZCC5LXE';
+
+    const ALGORITHM_DEFAULT = 'sha1';
+
+    const ALGORITHM_CUSTOM = 'sha256';
+
+    const DIGITS_DEFAULT = 6;
+
+    const DIGITS_CUSTOM = 7;
+
+    const DIGITS_STEAM = 5;
+
+    const PERIOD_DEFAULT = 30;
+
+    const PERIOD_CUSTOM = 40;
+
+    const COUNTER_DEFAULT = 0;
+
+    const COUNTER_CUSTOM = 5;
+
+    const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
+
+    const ICON = 'test.png';
+
+    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;
+
+    const HOTP_FULL_CUSTOM_URI_NO_IMG = 'otpauth://hotp/' . self::SERVICE . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE . '&digits=' . self::DIGITS_CUSTOM . '&counter=' . self::COUNTER_CUSTOM . '&algorithm=' . self::ALGORITHM_CUSTOM;
+
+    const HOTP_FULL_CUSTOM_URI = self::HOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::IMAGE;
+
+    const TOTP_SHORT_URI = 'otpauth://totp/' . self::ACCOUNT . '?secret=' . self::SECRET;
+
+    const HOTP_SHORT_URI = 'otpauth://hotp/' . self::ACCOUNT . '?secret=' . self::SECRET;
+
+    const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=https%3A%2F%2Fen.opensuse.org%2Fimage.png';
+
+    const INVALID_OTPAUTH_URI = 'otpauth://Xotp/' . self::ACCOUNT . '?secret=' . self::SECRET;
+
+    const STEAM_TOTP_URI = 'otpauth://totp/' . self::STEAM . ':' . self::ACCOUNT . '?secret=' . self::STEAM_SECRET . '&issuer=' . self::STEAM . '&digits=' . self::DIGITS_STEAM . '&period=30&algorithm=' . self::ALGORITHM_DEFAULT;
+
+    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_TOTP = [
+        'service'   => self::SERVICE,
+        'account'   => self::ACCOUNT,
+        'icon'      => self::ICON,
+        'otp_type'  => 'totp',
+        'secret'    => self::SECRET,
+        'digits'    => self::DIGITS_CUSTOM,
+        'algorithm' => self::ALGORITHM_CUSTOM,
+        'period'    => self::PERIOD_CUSTOM,
+        'counter'   => null,
+    ];
+
+    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_TOTP = [
+        'account'  => self::ACCOUNT,
+        'otp_type' => 'totp',
+        'secret'   => self::SECRET,
+    ];
+
+    const ARRAY_OF_PARAMETERS_FOR_UNSUPPORTED_OTP_TYPE = [
+        'account'  => self::ACCOUNT,
+        'otp_type' => 'Xotp',
+        'secret'   => self::SECRET,
+    ];
+
+    const ARRAY_OF_INVALID_PARAMETERS_FOR_TOTP = [
+        'account'  => self::ACCOUNT,
+        'otp_type' => 'totp',
+        'secret'   => 0,
+    ];
+
+    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_CUSTOM_HOTP = [
+        'service'   => self::SERVICE,
+        'account'   => self::ACCOUNT,
+        'icon'      => self::ICON,
+        'otp_type'  => 'hotp',
+        'secret'    => self::SECRET,
+        'digits'    => self::DIGITS_CUSTOM,
+        'algorithm' => self::ALGORITHM_CUSTOM,
+        'period'    => null,
+        'counter'   => self::COUNTER_CUSTOM,
+    ];
+
+    const ARRAY_OF_MINIMUM_VALID_PARAMETERS_FOR_HOTP = [
+        'account'  => self::ACCOUNT,
+        'otp_type' => 'hotp',
+        'secret'   => self::SECRET,
+    ];
+
+    const ARRAY_OF_FULL_VALID_PARAMETERS_FOR_STEAM_TOTP = [
+        'service'   => self::STEAM,
+        'account'   => self::ACCOUNT,
+        'otp_type'  => 'steamtotp',
+        'secret'    => self::STEAM_SECRET,
+        'digits'    => self::DIGITS_STEAM,
+        'algorithm' => self::ALGORITHM_DEFAULT,
+        'period'    => self::PERIOD_DEFAULT,
+        'counter'   => null,
+    ];
+
+}

+ 26 - 3
tests/Feature/Http/Auth/ForgotPasswordControllerTest.php

@@ -9,6 +9,12 @@ use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Notification;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\ForgotPasswordController
+ * @covers  \App\Models\User
+ * @covers  \App\Http\Middleware\RejectIfDemoMode
+ * @covers  \App\Http\Middleware\RejectIfAuthenticated
+ */
 class ForgotPasswordControllerTest extends FeatureTestCase
 {
     /**
@@ -26,7 +32,7 @@ class ForgotPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['email']);
+            ->assertJsonValidationErrors(['email']);
     }
 
     /**
@@ -39,7 +45,7 @@ class ForgotPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['email']);
+            ->assertJsonValidationErrors(['email']);
     }
 
     /**
@@ -52,7 +58,7 @@ class ForgotPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['email']);
+            ->assertJsonValidationErrors(['email']);
     }
 
     /**
@@ -91,4 +97,21 @@ class ForgotPasswordControllerTest extends FeatureTestCase
 
         $response->assertStatus(401);
     }
+
+    /**
+     * @test
+     */
+    public function test_submit_email_password_request_when_authenticated_returns_bad_request()
+    {
+        $user = User::factory()->create();
+
+        $this->actingAs($user, 'web-guard')
+            ->json('POST', '/user/password/lost', [
+                'email' => $user->email,
+            ])
+            ->assertStatus(400)
+            ->assertJsonStructure([
+                'message',
+            ]);
+    }
 }

+ 51 - 18
tests/Feature/Http/Auth/LoginTest.php

@@ -5,7 +5,15 @@ namespace Tests\Feature\Http\Auth;
 use App\Facades\Settings;
 use App\Models\User;
 use Tests\FeatureTestCase;
-
+use Illuminate\Support\Carbon;
+
+/**
+ * @covers  \App\Http\Controllers\Auth\LoginController
+ * @covers  \App\Http\Middleware\RejectIfAuthenticated
+ * @covers  \App\Http\Middleware\RejectIfReverseProxy
+ * @covers  \App\Http\Middleware\RejectIfDemoMode
+ * @covers  \App\Http\Middleware\SkipIfAuthenticated
+ */
 class LoginTest extends FeatureTestCase
 {
     /**
@@ -20,7 +28,7 @@ class LoginTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -36,15 +44,35 @@ class LoginTest extends FeatureTestCase
             'email'    => $this->user->email,
             'password' => self::PASSWORD,
         ])
-        ->assertOk()
-        ->assertExactJson([
-            'message' => 'authenticated',
-            'name'    => $this->user->name,
-        ]);
+            ->assertOk()
+            ->assertExactJson([
+                'message' => 'authenticated',
+                'name'    => $this->user->name,
+            ]);
+    }
+
+    /**
+     * @test
+     * 
+     * @covers  \App\Rules\CaseInsensitiveEmailExists
+     */
+    public function test_user_login_with_uppercased_email_returns_success()
+    {
+        $response = $this->json('POST', '/user/login', [
+            'email'    => strtoupper($this->user->email),
+            'password' => self::PASSWORD,
+        ])
+            ->assertOk()
+            ->assertExactJson([
+                'message' => 'authenticated',
+                'name'    => $this->user->name,
+            ]);
     }
 
     /**
      * @test
+     * 
+     * @covers  \App\Http\Middleware\SkipIfAuthenticated
      */
     public function test_user_login_already_authenticated_returns_bad_request()
     {
@@ -74,26 +102,28 @@ class LoginTest extends FeatureTestCase
             'email'    => '',
             'password' => '',
         ])
-        ->assertStatus(422)
-        ->assertJsonValidationErrors([
-            'email',
-            'password',
-        ]);
+            ->assertStatus(422)
+            ->assertJsonValidationErrors([
+                'email',
+                'password',
+            ]);
     }
 
     /**
      * @test
+     * 
+     * @covers  \App\Exceptions\Handler
      */
-    public function test_user_login_with_invalid_credentials_returns_validation_error()
+    public function test_user_login_with_invalid_credentials_returns_authentication_error()
     {
         $response = $this->json('POST', '/user/login', [
             'email'    => $this->user->email,
             'password' => self::WRONG_PASSWORD,
         ])
-        ->assertStatus(401)
-        ->assertJson([
-            'message' => 'unauthorised',
-        ]);
+            ->assertStatus(401)
+            ->assertJson([
+                'message' => 'unauthorised',
+            ]);
     }
 
     /**
@@ -154,6 +184,9 @@ class LoginTest extends FeatureTestCase
 
     /**
      * @test
+     * 
+     * @covers  \App\Http\Middleware\KickOutInactiveUser
+     * @covers  \App\Http\Middleware\LogUserLastSeen
      */
     public function test_user_logout_after_inactivity_returns_teapot()
     {
@@ -169,7 +202,7 @@ class LoginTest extends FeatureTestCase
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('GET', '/api/v1/twofaccounts');
 
-        sleep(61);
+        $this->travelTo(Carbon::now()->addMinutes(2));
 
         $response = $this->actingAs($this->user, 'api-guard')
             ->json('GET', '/api/v1/twofaccounts')

+ 4 - 1
tests/Feature/Http/Auth/PasswordControllerTest.php

@@ -5,6 +5,9 @@ namespace Tests\Feature\Http\Auth;
 use App\Models\User;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\PasswordController
+ */
 class PasswordControllerTest extends FeatureTestCase
 {
     /**
@@ -19,7 +22,7 @@ class PasswordControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 15 - 10
tests/Feature/Http/Auth/RegisterControllerTest.php

@@ -6,6 +6,9 @@ use App\Models\User;
 use Illuminate\Support\Facades\DB;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\RegisterController
+ */
 class RegisterControllerTest extends FeatureTestCase
 {
     private const USERNAME = 'john doe';
@@ -17,7 +20,7 @@ class RegisterControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
     }
@@ -35,18 +38,20 @@ class RegisterControllerTest extends FeatureTestCase
             'password'              => self::PASSWORD,
             'password_confirmation' => self::PASSWORD,
         ])
-        ->assertCreated()
-        ->assertJsonStructure([
-            'message',
-            'name',
-        ])
-        ->assertJsonFragment([
-            'name' => self::USERNAME,
-        ]);
+            ->assertCreated()
+            ->assertJsonStructure([
+                'message',
+                'name',
+            ])
+            ->assertJsonFragment([
+                'name' => self::USERNAME,
+            ]);
     }
 
     /**
      * @test
+     * 
+     * @covers  \App\Rules\FirstUser
      */
     public function test_register_returns_already_an_existing_user()
     {
@@ -59,7 +64,7 @@ class RegisterControllerTest extends FeatureTestCase
             'password'              => self::PASSWORD,
             'password_confirmation' => self::PASSWORD,
         ])
-        ->assertJsonValidationErrorFor('name');
+            ->assertJsonValidationErrorFor('name');
     }
 
     /**

+ 7 - 3
tests/Feature/Http/Auth/ResetPasswordControllerTest.php

@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\Notification;
 use Illuminate\Support\Facades\Password;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\ResetPasswordController
+ * @covers  \App\Models\User
+ */
 class ResetPasswordControllerTest extends FeatureTestCase
 {
     /**
@@ -28,7 +32,7 @@ class ResetPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['email', 'password', 'token']);
+            ->assertJsonValidationErrors(['email', 'password', 'token']);
     }
 
     /**
@@ -44,7 +48,7 @@ class ResetPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['email', 'password']);
+            ->assertJsonValidationErrors(['email', 'password']);
     }
 
     /**
@@ -60,7 +64,7 @@ class ResetPasswordControllerTest extends FeatureTestCase
         ]);
 
         $response->assertStatus(422)
-                 ->assertJsonValidationErrors(['password']);
+            ->assertJsonValidationErrors(['password']);
     }
 
     /**

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

@@ -7,6 +7,10 @@ use App\Models\User;
 use Illuminate\Support\Facades\Config;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\UserController
+ * @covers  \App\Http\Middleware\RejectIfDemoMode
+ */
 class UserControllerTest extends FeatureTestCase
 {
     /**
@@ -23,7 +27,7 @@ class UserControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 154 - 7
tests/Feature/Http/Auth/WebAuthnDeviceLostControllerTest.php

@@ -5,7 +5,16 @@ namespace Tests\Feature\Http\Auth;
 use App\Models\User;
 use Illuminate\Support\Facades\Notification;
 use Tests\FeatureTestCase;
+use App\Notifications\WebauthnRecoveryNotification;
+use Illuminate\Support\Facades\Lang;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\WebAuthnDeviceLostController
+ * @covers  \App\Notifications\WebauthnRecoveryNotification
+ * @covers  \App\Extensions\WebauthnCredentialBroker
+ * @covers  \App\Http\Requests\WebauthnDeviceLostRequest
+ * @covers  \App\Providers\AuthServiceProvider
+ */
 class WebAuthnDeviceLostControllerTest extends FeatureTestCase
 {
     /**
@@ -16,7 +25,7 @@ class WebAuthnDeviceLostControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -25,6 +34,7 @@ class WebAuthnDeviceLostControllerTest extends FeatureTestCase
 
     /**
      * @test
+     * @covers  \App\Models\Traits\WebAuthnManageCredentials
      */
     public function test_sendRecoveryEmail_sends_notification_on_success()
     {
@@ -34,18 +44,83 @@ class WebAuthnDeviceLostControllerTest extends FeatureTestCase
             'email' => $this->user->email,
         ]);
 
-        Notification::assertSentTo($this->user, \App\Notifications\WebauthnRecoveryNotification::class);
+        Notification::assertSentTo($this->user, WebauthnRecoveryNotification::class);
 
         $response->assertStatus(200)
-        ->assertJsonStructure([
-            'message',
+            ->assertJsonStructure([
+                'message',
+            ]);
+
+        $this->assertDatabaseHas('webauthn_recoveries', [
+            'email' => $this->user->email
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_WebauthnRecoveryNotification_renders_to_email()
+    {
+        $mail = (new WebauthnRecoveryNotification('test_token'))->toMail($this->user)->render();
+
+        $this->assertStringContainsString(
+            'http://localhost/webauthn/recover?token=test_token&amp;email=' . urlencode($this->user->email),
+            $mail
+        );
+
+        $this->assertStringContainsString(
+            Lang::get('Recover Account'),
+            $mail
+        );
+
+        $this->assertStringContainsString(
+            Lang::get(
+                'You are receiving this email because we received an account recovery request for your account.'
+            ),
+            $mail
+        );
+
+        $this->assertStringContainsString(
+            Lang::get(
+                'This recovery link will expire in :count minutes.',
+                ['count' => config('auth.passwords.webauthn.expire')]
+            ),
+            $mail
+        );
+
+        $this->assertStringContainsString(
+            Lang::get('If you did not request an account recovery, no further action is required.'),
+            $mail
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function test_sendRecoveryEmail_does_not_send_anything_to_unknown_email()
+    {
+        Notification::fake();
+
+        $response = $this->json('POST', '/webauthn/lost', [
+            'email' => 'bad@email.com',
+        ]);
+
+        Notification::assertNothingSent();
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors([
+                'email',
+            ]);
+
+        $this->assertDatabaseMissing('webauthn_recoveries', [
+            'email' => 'bad@email.com'
         ]);
     }
 
     /**
      * @test
      */
-    public function test_sendRecoveryEmail_does_not_send_anything_on_error()
+    public function test_sendRecoveryEmail_does_not_send_anything_to_invalid_email()
     {
         Notification::fake();
 
@@ -56,8 +131,80 @@ class WebAuthnDeviceLostControllerTest extends FeatureTestCase
         Notification::assertNothingSent();
 
         $response->assertStatus(422)
-        ->assertJsonValidationErrors([
-            'email',
+            ->assertJsonValidationErrors([
+                'email',
+            ]);
+
+        $this->assertDatabaseMissing('webauthn_recoveries', [
+            'email' => 'bad@email.com'
         ]);
     }
+
+    /**
+     * @test
+     */
+    public function test_sendRecoveryEmail_does_not_send_anything_to_not_WebAuthnAuthenticatable()
+    {
+        $mock = $this->mock(\App\Extensions\WebauthnCredentialBroker::class)->makePartial();
+        $mock->shouldReceive('getUser')
+            ->andReturn(new \Illuminate\Foundation\Auth\User());
+
+        Notification::fake();
+
+        $response = $this->json('POST', '/webauthn/lost', [
+            'email' => $this->user->email,
+        ]);
+
+        Notification::assertNothingSent();
+
+        $response->assertStatus(422)
+            ->assertJsonValidationErrors([
+                'email',
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_sendRecoveryEmail_is_throttled()
+    {
+        Notification::fake();
+
+        $response = $this->json('POST', '/webauthn/lost', [
+            'email' => $this->user->email,
+        ]);
+
+        Notification::assertSentTo($this->user, WebauthnRecoveryNotification::class);
+
+        $response->assertStatus(200)
+            ->assertJsonStructure([
+                'message',
+            ]);
+
+        $this->assertDatabaseHas('webauthn_recoveries', [
+            'email' => $this->user->email
+        ]);
+
+        $this->json('POST', '/webauthn/lost', [
+            'email' => $this->user->email,
+        ])
+            ->assertStatus(422)
+            ->assertJsonValidationErrorfor('email')
+            ->assertJsonFragment([
+                'message' => __('passwords.throttled')
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_error_if_no_broker_is_set()
+    {
+        $this->app['config']->set('auth.passwords.webauthn', null);
+
+        $this->json('POST', '/webauthn/lost', [
+            'email' => $this->user->email
+        ])
+            ->assertStatus(500);
+    }
 }

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

@@ -6,7 +6,14 @@ use App\Models\User;
 use Illuminate\Support\Facades\DB;
 use Laragear\WebAuthn\Http\Requests\AssertedRequest;
 use Tests\FeatureTestCase;
+use Laragear\WebAuthn\WebAuthn;
+use Illuminate\Support\Facades\Config;
+use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\WebAuthnLoginController
+ * @covers  \App\Models\User
+ */
 class WebAuthnLoginControllerTest extends FeatureTestCase
 {
     /**
@@ -15,15 +22,44 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
     protected $user;
 
     const CREDENTIAL_ID = 's06aG41wsIYh5X1YUhB-SlH8y3F2RzdJZVse8iXRXOCd3oqQdEyCOsBawzxrYBtJRQA2azAMEN_q19TUp6iMgg';
+    const CREDENTIAL_ID_ALT = '-VOLFKPY-_FuMI_sJ7gMllK76L3VoRUINj6lL_Z3qDg';
+    const CREDENTIAL_ID_ALT_RAW = '+VOLFKPY+/FuMI/sJ7gMllK76L3VoRUINj6lL/Z3qDg=';
 
     const PUBLIC_KEY = 'eyJpdiI6ImYyUHlJOEJML0pwTXJ2UDkveTQwZFE9PSIsInZhbHVlIjoiQWFSYi9LVEszazlBRUZsWHp0cGNRNktGeEQ3aTBsbU9zZ1g5MEgrWFJJNmgraElsNU9hV0VsRVlWc3NoUVVHUjRRdlcxTS9pVklnOWtVYWY5TFJQTTFhR1Rxb1ZzTFkxTWE4VUVvK1lyU3pYQ1M3VlBMWWxZcDVaYWFnK25iaXVyWGR6ZFRmMFVoSmdPZ3UvSnptbVZER0FYdEEyYmNYcW43RkV5aTVqSjNwZEFsUjhUYSs0YjU2Z2V2bUJXa0E0aVB1VC8xSjdJZ2llRGlHY2RwOGk3MmNPTyt6eDFDWUs1dVBOSWp1ZUFSeUlkclgwRW16RE9sUUpDSWV6Sk50TSIsIm1hYyI6IjI3ODQ5NzcxZGY1MzMwYTNiZjAwZmEwMDJkZjYzMGU4N2UzZjZlOGM0ZWE3NDkyYWMxMThhNmE5NWZiMTVjNGEiLCJ0YWciOiIifQ==';
 
     const USER_ID = '3b758ac868b74307a7e96e69ae187339';
+    const USER_ID_ALT = 'e8af6f703f8042aa91c30cf72289aa07';
+
+    const ASSERTION_RESPONSE = [
+        'id' => self::CREDENTIAL_ID_ALT,
+        'rawId' => self::CREDENTIAL_ID_ALT_RAW,
+        'type' => 'public-key',
+        'response' => [
+            'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
+            'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
+            'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
+            'userHandle' => self::USER_ID_ALT,
+        ]
+    ];
+
+    const ASSERTION_RESPONSE_NO_HANDLE = [
+        'id' => self::CREDENTIAL_ID_ALT,
+        'rawId' => self::CREDENTIAL_ID_ALT_RAW,
+        'type' => 'public-key',
+        'response' => [
+            'clientDataJSON' => 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVhvem15bktpLVlEMmlSdktOYlNQQSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJjcm9zc09yaWdpbiI6ZmFsc2V9',
+            'authenticatorData' => 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAQ==',
+            'signature' => 'ca4IJ9h8bZnjMbEFuHX1zfX5LcbiPyDVz6sD1/ppR4t8++1DxKa5EdBIrfNlo8FSOv/JSzMrGGUCQvc/Ngj1KnZpO3s9OdTb54/gMDewH/K8EG4wSvxzHdL6sMbP7UUc5Wq1pcdu9MgXY8V+1gftXpzcoaae0X+mLEETgU7eB8jG0mZhVWvE4yQKuDnZA1i9r8oQhqsvG4nUw1BxvR8wAGiRR+R287LaL41k+xum5mS8zEojUmuLSH50miyVxZ4Y+/oyfxG7i+wSYGNSXlW5iNPB+2WupGS7ce4TuOgaFeMmP2a9rzP4m2IBSQoJ2FyrdzR7HwBEewqqrUVbGQw3Aw==',
+            'userHandle' => null,
+        ]
+    ];
+
+    const ASSERTION_CHALLENGE = 'iXozmynKi+YD2iRvKNbSPA==';
 
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -47,6 +83,42 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
             ->assertNoContent();
     }
 
+    /**
+     * @test
+     */
+    public function test_webauthn_login_merge_handle_if_missing()
+    {
+        $this->user = User::factory()->create();
+
+        DB::table('webauthn_credentials')->insert([
+            'id'                   => self::CREDENTIAL_ID_ALT,
+            'authenticatable_type' => \App\Models\User::class,
+            'authenticatable_id'   => $this->user->id,
+            'user_id'              => self::USER_ID_ALT,
+            'counter'              => 0,
+            'rp_id'                => 'http://localhost',
+            'origin'               => 'http://localhost',
+            'aaguid'               => '00000000-0000-0000-0000-000000000000',
+            'attestation_format'   => 'none',
+            'public_key'           => self::PUBLIC_KEY,
+            'updated_at'           => now(),
+            'created_at'           => now(),
+        ]);
+
+        $this->session(['_webauthn' => new \Laragear\WebAuthn\Challenge(
+            new \Laragear\WebAuthn\ByteBuffer(base64_decode(self::ASSERTION_CHALLENGE)),
+            60,
+            false,
+        )]);
+
+        $this->mock(AssertionValidator::class)
+            ->expects('send->thenReturn')
+            ->andReturn();
+
+        $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_NO_HANDLE)
+            ->assertNoContent();
+    }
+
     /**
      * @test
      */
@@ -84,22 +156,24 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
         ];
 
         $response = $this->json('POST', '/webauthn/login', $data)
-        ->assertStatus(422)
-        ->assertJsonValidationErrors([
-            'id',
-            'rawId',
-            'type',
-            'response.authenticatorData',
-            'response.clientDataJSON',
-            'response.signature',
-        ]);
+            ->assertStatus(422)
+            ->assertJsonValidationErrors([
+                'id',
+                'rawId',
+                'type',
+                'response.authenticatorData',
+                'response.clientDataJSON',
+                'response.signature',
+            ]);
     }
 
     /**
      * @test
      */
-    public function test_get_options_returns_success()
+    public function test_get_options_for_securelogin_returns_success()
     {
+        Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_REQUIRED);
+
         $this->user = User::factory()->create();
 
         DB::table('webauthn_credentials')->insert([
@@ -118,18 +192,59 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
         ]);
 
         $response = $this->json('POST', '/webauthn/login/options')
-        ->assertOk()
-        ->assertJsonStructure([
-            'challenge',
-            'userVerification',
-            'timeout',
-        ])
-        ->assertJsonFragment([
-            'allowCredentials' => [[
-                'id'   => self::CREDENTIAL_ID,
-                'type' => 'public-key',
-            ]],
+            ->assertOk()
+            ->assertJsonStructure([
+                'challenge',
+                'userVerification',
+                'timeout',
+            ])
+            ->assertJsonFragment([
+                'userVerification' => 'required',
+                'allowCredentials' => [[
+                    'id'   => self::CREDENTIAL_ID,
+                    'type' => 'public-key',
+                ]],
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_get_options_for_fastlogin_returns_success()
+    {
+        Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_DISCOURAGED);
+
+        $this->user = User::factory()->create();
+
+        DB::table('webauthn_credentials')->insert([
+            'id'                   => self::CREDENTIAL_ID,
+            'authenticatable_type' => \App\Models\User::class,
+            'authenticatable_id'   => $this->user->id,
+            'user_id'              => self::USER_ID,
+            'counter'              => 0,
+            'rp_id'                => 'http://localhost',
+            'origin'               => 'http://localhost',
+            'aaguid'               => '00000000-0000-0000-0000-000000000000',
+            'attestation_format'   => 'none',
+            'public_key'           => self::PUBLIC_KEY,
+            'updated_at'           => now(),
+            'created_at'           => now(),
         ]);
+
+        $response = $this->json('POST', '/webauthn/login/options')
+            ->assertOk()
+            ->assertJsonStructure([
+                'challenge',
+                'userVerification',
+                'timeout',
+            ])
+            ->assertJsonFragment([
+                'userVerification' => 'discouraged',
+                'allowCredentials' => [[
+                    'id'   => self::CREDENTIAL_ID,
+                    'type' => 'public-key',
+                ]],
+            ]);
     }
 
     /**
@@ -138,9 +253,9 @@ class WebAuthnLoginControllerTest extends FeatureTestCase
     public function test_get_options_with_no_registred_user_returns_error()
     {
         $this->json('POST', '/webauthn/login/options')
-        ->assertStatus(400)
-        ->assertJsonStructure([
-            'message',
-        ]);
+            ->assertStatus(400)
+            ->assertJsonStructure([
+                'message',
+            ]);
     }
 }

+ 6 - 1
tests/Feature/Http/Auth/WebAuthnManageControllerTest.php

@@ -7,6 +7,11 @@ use Illuminate\Foundation\Testing\WithoutMiddleware;
 use Illuminate\Support\Facades\DB;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\WebAuthnManageController
+ * @covers  \App\Http\Middleware\RejectIfReverseProxy
+ * @covers  \App\Models\Traits\WebAuthnManageCredentials
+ */
 class WebAuthnManageControllerTest extends FeatureTestCase
 {
     // use WithoutMiddleware;
@@ -23,7 +28,7 @@ class WebAuthnManageControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 71 - 10
tests/Feature/Http/Auth/WebAuthnRecoveryControllerTest.php

@@ -8,6 +8,12 @@ use Illuminate\Support\Facades\Date;
 use Illuminate\Support\Facades\DB;
 use Tests\FeatureTestCase;
 
+/**
+ * @covers  \App\Http\Controllers\Auth\WebAuthnRecoveryController
+ * @covers  \App\Extensions\WebauthnCredentialBroker
+ * @covers  \App\Http\Requests\WebauthnRecoveryRequest
+ * @covers  \App\Providers\AuthServiceProvider
+ */
 class WebAuthnRecoveryControllerTest extends FeatureTestCase
 {
     /**
@@ -29,7 +35,7 @@ class WebAuthnRecoveryControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -47,16 +53,55 @@ class WebAuthnRecoveryControllerTest extends FeatureTestCase
     /**
      * @test
      */
-    public function test_recover_with_invalid_token_returns_validation_error()
+    public function test_recover_fails_if_no_recovery_is_set()
+    {
+        DB::table('webauthn_recoveries')->delete();
+
+        $this->json('POST', '/webauthn/recover', [
+            'token'    => self::ACTUAL_TOKEN_VALUE,
+            'email'    => $this->user->email,
+            'password' => UserFactory::USER_PASSWORD,
+        ])
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('token');
+    }
+
+    /**
+     * @test
+     */
+    public function test_recover_with_wrong_token_returns_validation_error()
     {
         $response = $this->json('POST', '/webauthn/recover', [
-            'token'    => 'bad_token',
+            'token'    => 'wrong_token',
             'email'    => $this->user->email,
             'password' => UserFactory::USER_PASSWORD,
         ])
-        ->assertStatus(422)
-        ->assertJsonMissingValidationErrors('email')
-        ->assertJsonValidationErrors('token');
+            ->assertStatus(422)
+            ->assertJsonMissingValidationErrors('email')
+            ->assertJsonValidationErrors('token');
+    }
+
+    /**
+     * @test
+     */
+    public function test_recover_with_expired_token_returns_validation_error()
+    {
+        Date::setTestNow($now = Date::create(2020, 01, 01, 16, 30));
+
+        DB::table('webauthn_recoveries')->delete();
+        DB::table('webauthn_recoveries')->insert([
+            'token'      => self::STORED_TOKEN_VALUE,
+            'email'      => $this->user->email,
+            'created_at' => $now->clone()->subHour()->subSecond()->toDateTimeString(),
+        ]);
+
+        $this->json('POST', '/webauthn/recover', [
+            'token' => self::ACTUAL_TOKEN_VALUE,
+            'email' => $this->user->email,
+            'password' => UserFactory::USER_PASSWORD,
+        ])
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('token');
     }
 
     /**
@@ -64,12 +109,28 @@ class WebAuthnRecoveryControllerTest extends FeatureTestCase
      */
     public function test_recover_with_invalid_password_returns_authentication_error()
     {
-        $response = $this->json('POST', '/webauthn/recover', [
+        $this->json('POST', '/webauthn/recover', [
             'token'    => self::ACTUAL_TOKEN_VALUE,
             'email'    => $this->user->email,
             'password' => 'bad_password',
         ])
-        ->assertStatus(401);
+            ->assertStatus(401);
+    }
+
+    /**
+     * @test
+     */
+    public function test_recover_returns_validation_error_when_no_user_exists()
+    {
+        $this->json('POST', '/webauthn/recover', [
+            'token'    => self::ACTUAL_TOKEN_VALUE,
+            'email'    => 'no@user.com',
+            'password' => UserFactory::USER_PASSWORD,
+        ])
+            ->assertStatus(422)
+            ->assertJsonMissingValidationErrors('password')
+            ->assertJsonMissingValidationErrors('token')
+            ->assertJsonValidationErrors('email');
     }
 
     /**
@@ -82,7 +143,7 @@ class WebAuthnRecoveryControllerTest extends FeatureTestCase
             'email'    => $this->user->email,
             'password' => UserFactory::USER_PASSWORD,
         ])
-        ->assertStatus(200);
+            ->assertStatus(200);
 
         $this->assertDatabaseMissing('webauthn_recoveries', [
             'token' => self::STORED_TOKEN_VALUE,
@@ -119,7 +180,7 @@ class WebAuthnRecoveryControllerTest extends FeatureTestCase
             'password'  => UserFactory::USER_PASSWORD,
             'revokeAll' => true,
         ])
-        ->assertStatus(200);
+            ->assertStatus(200);
 
         $this->assertDatabaseMissing('webauthn_credentials', [
             'authenticatable_id' => $this->user->id,

+ 79 - 0
tests/Feature/Http/Auth/WebAuthnRegisterControllerTest.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Tests\Feature\Http\Auth;
+
+use App\Models\User;
+use Tests\FeatureTestCase;
+use Laragear\WebAuthn\Http\Requests\AttestedRequest;
+use Laragear\WebAuthn\Http\Requests\AttestationRequest;
+use Illuminate\Support\Facades\Config;
+use Laragear\WebAuthn\WebAuthn;
+use Laragear\WebAuthn\JsonTransport;
+
+/**
+ * @covers  \App\Http\Controllers\Auth\WebAuthnRegisterController
+ */
+class WebAuthnRegisterControllerTest extends FeatureTestCase
+{
+
+    /**
+     * @var \App\Models\User
+     */
+    protected $user;
+
+    /**
+     * @test
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->user = User::factory()->create();
+    }
+
+    /**
+     * @test
+     */
+    public function test_uses_attestation_with_fastRegistration_request(): void
+    {
+        Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_DISCOURAGED);
+
+        $request = $this->mock(AttestationRequest::class);
+
+        $request->expects('fastRegistration')->andReturnSelf();
+        $request->expects('toCreate')->andReturn(new JsonTransport());
+
+        $this->actingAs($this->user, 'web-guard')
+            ->json('POST', '/webauthn/register/options')
+            ->assertOk();
+    }
+
+    /**
+     * @test
+     */
+    public function test_uses_attestation_with_secureRegistration_request(): void
+    {
+        Config::set('webauthn.user_verification', WebAuthn::USER_VERIFICATION_REQUIRED);
+
+        $request = $this->mock(AttestationRequest::class);
+
+        $request->expects('secureRegistration')->andReturnSelf();
+        $request->expects('toCreate')->andReturn(new JsonTransport());
+
+        $this->actingAs($this->user, 'web-guard')
+            ->json('POST', '/webauthn/register/options')
+            ->assertOk();
+    }
+
+    /**
+     * @test
+     */
+    public function test_register_uses_attested_request(): void
+    {
+        $this->mock(AttestedRequest::class)->expects('save')->andReturn();
+
+        $this->actingAs($this->user, 'web-guard')
+            ->json('POST', '/webauthn/register')
+            ->assertNoContent();
+    }
+}

+ 122 - 0
tests/Feature/Http/SystemControllerTest.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace Tests\Api\v1\Controllers;
+
+use App\Models\User;
+use Illuminate\Foundation\Testing\WithoutMiddleware;
+use App\Services\ReleaseRadarService;
+use Tests\FeatureTestCase;
+
+/**
+ * @covers \App\Http\Controllers\SystemController
+ */
+class SystemControllerTest extends FeatureTestCase
+{
+    use WithoutMiddleware;
+
+    /**
+     * @var \App\Models\User
+     */
+    protected $user;
+
+    /**
+     * @test
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->user = User::factory()->create();
+    }
+
+    /**
+     * @test
+     */
+    public function test_infos_returns_only_base_collection()
+    {
+        $response = $this->json('GET', '/infos')
+            ->assertOk()
+            ->assertJsonStructure([
+                'Date',
+                'userAgent',
+                'Version',
+                'Environment',
+                'Debug',
+                'Cache driver',
+                'Log channel',
+                'Log level',
+                'DB driver',
+                'PHP version',
+                'Operating system',
+                'interface',
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_infos_returns_full_collection_when_signed_in()
+    {
+        $response = $this->actingAs($this->user, 'api-guard')
+            ->json('GET', '/infos')
+            ->assertOk()
+            ->assertJsonStructure([
+                'Auth guard',
+                'webauthn user verification',
+                'Trusted proxies',
+                'options' => [
+                    'showTokenAsDot',
+                    'closeOtpOnCopy',
+                    'copyOtpOnDisplay',
+                    'useBasicQrcodeReader',
+                    'displayMode',
+                    'showAccountsIcons',
+                    'kickUserAfter',
+                    'activeGroup',
+                    'rememberActiveGroup',
+                    'defaultGroup',
+                    'useEncryption',
+                    'defaultCaptureMode',
+                    'useDirectCapture',
+                    'useWebauthnAsDefault',
+                    'useWebauthnOnly',
+                    'getOfficialIcons',
+                    'checkForUpdate',
+                    'lastRadarScan',
+                    'latestRelease',
+                    'lang',
+                ],
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_infos_returns_full_collection_when_signed_in_behind_proxy()
+    {
+        $response = $this->actingAs($this->user, 'reverse-proxy-guard')
+            ->json('GET', '/infos')
+            ->assertOk()
+            ->assertJsonStructure([
+                'Auth proxy header for user',
+                'Auth proxy header for email',
+            ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_latestrelease_runs_manual_scan()
+    {
+        $releaseRadarService = $this->mock(ReleaseRadarService::class)->makePartial();
+        $releaseRadarService->shouldReceive('manualScan')
+            ->once()
+            ->andReturn('new_release');
+
+        $response = $this->json('GET', '/latestRelease')
+            ->assertOk()
+            ->assertJson([
+                'newRelease' => 'new_release',
+            ]);
+    }
+}

+ 204 - 4
tests/Feature/Models/TwoFAccountModelTest.php

@@ -3,8 +3,14 @@
 namespace Tests\Feature\Models;
 
 use App\Models\TwoFAccount;
-use Tests\Classes\OtpTestData;
+use Tests\Data\OtpTestData;
 use Tests\FeatureTestCase;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Http\Testing\FileFactory;
+use Illuminate\Support\Facades\Http;
+use App\Helpers\Helpers;
+use Mockery\MockInterface;
+use Tests\Data\HttpRequestTestData;
 
 /**
  * @covers \App\Models\TwoFAccount
@@ -21,10 +27,15 @@ class TwoFAccountModelTest extends FeatureTestCase
      */
     protected $customHotpTwofaccount;
 
+    /**
+     * 
+     */
+    const ICON_NAME = 'oDBngpjQaQAgLtHqGuYiPRqftCXv6Sj4hSAXARpA.png';
+
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -69,12 +80,36 @@ class TwoFAccountModelTest extends FeatureTestCase
 
     /**
      * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function test_fill_with_custom_totp_uri_returns_correct_value()
     {
+        $this->mock('alias:' . Helpers::class, function (MockInterface $helper) {
+            $helper->shouldReceive('getUniqueFilename')
+                ->andReturn(self::ICON_NAME);
+
+            $helper->shouldReceive('isValidImage')
+                ->andReturn(true);
+        });
+
+        $file = (new FileFactory)->image(self::ICON_NAME, 10, 10);
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response($file->tempFile, 200),
+        ]);
+
+        Storage::fake('imagesLink');
+        Storage::fake('icons');
+
         $twofaccount = new TwoFAccount;
         $twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI);
 
+        Storage::disk('icons')->assertExists(self::ICON_NAME);
+        Storage::disk('imagesLink')->assertMissing(self::ICON_NAME);
+
         $this->assertEquals('totp', $twofaccount->otp_type);
         $this->assertEquals(OtpTestData::TOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
         $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
@@ -84,7 +119,7 @@ class TwoFAccountModelTest extends FeatureTestCase
         $this->assertEquals(OtpTestData::PERIOD_CUSTOM, $twofaccount->period);
         $this->assertEquals(null, $twofaccount->counter);
         $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png', $twofaccount->icon);
+        $this->assertEquals(self::ICON_NAME, $twofaccount->icon);
     }
 
     /**
@@ -109,12 +144,36 @@ class TwoFAccountModelTest extends FeatureTestCase
 
     /**
      * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function test_fill_with_custom_hotp_uri_returns_correct_value()
     {
+        $this->mock('alias:' . Helpers::class, function (MockInterface $helper) {
+            $helper->shouldReceive('getUniqueFilename')
+                ->andReturn(self::ICON_NAME);
+
+            $helper->shouldReceive('isValidImage')
+                ->andReturn(true);
+        });
+
+        $file = (new FileFactory)->image(self::ICON_NAME, 10, 10);
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response($file->tempFile, 200),
+        ]);
+
+        Storage::fake('imagesLink');
+        Storage::fake('icons');
+
         $twofaccount = new TwoFAccount;
         $twofaccount->fillWithURI(OtpTestData::HOTP_FULL_CUSTOM_URI);
 
+        Storage::disk('icons')->assertExists(self::ICON_NAME);
+        Storage::disk('imagesLink')->assertMissing(self::ICON_NAME);
+
         $this->assertEquals('hotp', $twofaccount->otp_type);
         $this->assertEquals(OtpTestData::HOTP_FULL_CUSTOM_URI, $twofaccount->legacy_uri);
         $this->assertEquals(OtpTestData::SERVICE, $twofaccount->service);
@@ -124,7 +183,7 @@ class TwoFAccountModelTest extends FeatureTestCase
         $this->assertEquals(null, $twofaccount->period);
         $this->assertEquals(OtpTestData::COUNTER_CUSTOM, $twofaccount->counter);
         $this->assertEquals(OtpTestData::ALGORITHM_CUSTOM, $twofaccount->algorithm);
-        $this->assertStringEndsWith('.png', $twofaccount->icon);
+        $this->assertEquals(self::ICON_NAME, $twofaccount->icon);
     }
 
     /**
@@ -391,9 +450,28 @@ class TwoFAccountModelTest extends FeatureTestCase
 
     /**
      * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function test_getOTP_for_totp_returns_the_same_password()
     {
+        $this->mock('alias:' . Helpers::class, function (MockInterface $helper) {
+            $helper->shouldReceive('getUniqueFilename')
+                ->andReturn(self::ICON_NAME);
+
+            $helper->shouldReceive('isValidImage')
+                ->andReturn(true);
+        });
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response(HttpRequestTestData::ICON_PNG, 200),
+        ]);
+
+        Storage::fake('imagesLink');
+        Storage::fake('icons');
+
         $twofaccount = new TwoFAccount;
 
         $otp_from_model = $this->customTotpTwofaccount->getOTP();
@@ -413,9 +491,28 @@ class TwoFAccountModelTest extends FeatureTestCase
 
     /**
      * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function test_getOTP_for_hotp_returns_the_same_password()
     {
+        $this->mock('alias:' . Helpers::class, function (MockInterface $helper) {
+            $helper->shouldReceive('getUniqueFilename')
+                ->andReturn(self::ICON_NAME);
+
+            $helper->shouldReceive('isValidImage')
+                ->andReturn(true);
+        });
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response(HttpRequestTestData::ICON_PNG, 200),
+        ]);
+
+        Storage::fake('imagesLink');
+        Storage::fake('icons');
+
         $twofaccount = new TwoFAccount;
 
         $otp_from_model = $this->customHotpTwofaccount->getOTP();
@@ -507,4 +604,107 @@ class TwoFAccountModelTest extends FeatureTestCase
         $this->assertStringContainsString('counter=' . OtpTestData::COUNTER_CUSTOM, $uri);
         $this->assertStringContainsString('algorithm=' . OtpTestData::ALGORITHM_CUSTOM, $uri);
     }
+
+    /**
+     * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
+     */
+    public function test_fill_succeed_when_image_fetching_fails()
+    {
+        $this->mock('alias:' . Helpers::class, function (MockInterface $helper) {
+            $helper->shouldReceive('getUniqueFilename')
+                ->andReturn(self::ICON_NAME);
+        });
+
+        Http::preventStrayRequests();
+
+        Storage::fake('imagesLink');
+        Storage::fake('icons');
+
+        $twofaccount = new TwoFAccount;
+        $twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI);
+
+        Storage::disk('icons')->assertMissing(self::ICON_NAME);
+        Storage::disk('imagesLink')->assertMissing(self::ICON_NAME);
+    }
+
+    /**
+     * @test
+     */
+    public function test_saving_totp_without_period_set_default_one()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->service = OtpTestData::SERVICE;
+        $twofaccount->account = OtpTestData::ACCOUNT;
+        $twofaccount->otp_type = TwoFAccount::TOTP;
+        $twofaccount->secret = OtpTestData::SECRET;
+
+        $twofaccount->save();
+
+        $account = TwoFAccount::find($twofaccount->id);
+
+        $this->assertEquals(TwoFAccount::DEFAULT_PERIOD, $account->period);
+    }
+
+    /**
+     * @test
+     */
+    public function test_saving_hotp_without_counter_set_default_one()
+    {
+        $twofaccount = new TwoFAccount;
+        $twofaccount->service = OtpTestData::SERVICE;
+        $twofaccount->account = OtpTestData::ACCOUNT;
+        $twofaccount->otp_type = TwoFAccount::HOTP;
+        $twofaccount->secret = OtpTestData::SECRET;
+
+        $twofaccount->save();
+
+        $account = TwoFAccount::find($twofaccount->id);
+
+        $this->assertEquals(TwoFAccount::DEFAULT_COUNTER, $account->counter);
+    }
+
+    /**
+     * @test
+     */
+    public function test_equals_returns_true()
+    {
+        $twofaccount             = new TwoFAccount;
+        $twofaccount->legacy_uri = OtpTestData::TOTP_FULL_CUSTOM_URI;
+        $twofaccount->service    = OtpTestData::SERVICE;
+        $twofaccount->account    = OtpTestData::ACCOUNT;
+        $twofaccount->icon       = OtpTestData::ICON;
+        $twofaccount->otp_type   = 'totp';
+        $twofaccount->secret     = OtpTestData::SECRET;
+        $twofaccount->digits     = OtpTestData::DIGITS_CUSTOM;
+        $twofaccount->algorithm  = OtpTestData::ALGORITHM_CUSTOM;
+        $twofaccount->period     = OtpTestData::PERIOD_CUSTOM;
+        $twofaccount->counter    = null;
+        $twofaccount->save();
+
+        $this->assertTrue($twofaccount->equals($this->customTotpTwofaccount));
+    }
+
+    /**
+     * @test
+     */
+    public function test_equals_returns_false()
+    {
+        $twofaccount             = new TwoFAccount;
+        $twofaccount->legacy_uri = OtpTestData::TOTP_FULL_CUSTOM_URI;
+        $twofaccount->service    = OtpTestData::SERVICE;
+        $twofaccount->account    = OtpTestData::ACCOUNT;
+        $twofaccount->icon       = OtpTestData::ICON;
+        $twofaccount->otp_type   = 'totp';
+        $twofaccount->secret     = OtpTestData::SECRET;
+        $twofaccount->digits     = OtpTestData::DIGITS_CUSTOM;
+        $twofaccount->algorithm  = OtpTestData::ALGORITHM_CUSTOM;
+        $twofaccount->period     = OtpTestData::PERIOD_CUSTOM;
+        $twofaccount->counter    = null;
+        $twofaccount->save();
+
+        $this->assertFalse($twofaccount->equals($this->customHotpTwofaccount));
+    }
 }

+ 2 - 1
tests/Feature/Services/GroupServiceTest.php

@@ -10,6 +10,7 @@ use Tests\FeatureTestCase;
 
 /**
  * @covers \App\Services\GroupService
+ * @covers \App\Facades\Groups
  */
 class GroupServiceTest extends FeatureTestCase
 {
@@ -52,7 +53,7 @@ class GroupServiceTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 

+ 83 - 16
tests/Feature/Services/LogoServiceTest.php

@@ -6,6 +6,9 @@ use App\Services\LogoService;
 use Illuminate\Foundation\Testing\WithoutMiddleware;
 use Mockery\MockInterface;
 use Tests\TestCase;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Http;
+use Tests\Data\HttpRequestTestData;
 
 /**
  * @covers \App\Services\LogoService
@@ -17,7 +20,7 @@ class LogoServiceTest extends TestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
     }
@@ -25,18 +28,65 @@ class LogoServiceTest extends TestCase
     /**
      * @test
      */
-    public function test_getIcon_returns_iconFilename_when_logo_exists()
+    public function test_getIcon_returns_stored_icon_file_when_logo_exists()
     {
-        $logoServiceMock = $this->partialMock(LogoService::class, function (MockInterface $mock) {
-            $mock->shouldAllowMockingProtectedMethods();
-            $mock->shouldReceive('getLogo', 'copyToIcons')
-            ->once()
-            ->andReturn('service.svg', true);
-        });
+        $svgLogo = HttpRequestTestData::SVG_LOGO_BODY;
+        $tfaJsonBody = HttpRequestTestData::TFA_JSON_BODY;
 
-        $icon = $logoServiceMock->getIcon('service');
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response($svgLogo, 200),
+            'https://2fa.directory/api/v3/tfa.json' => Http::response($tfaJsonBody, 200),
+        ]);
+
+        Storage::fake('icons');
+        Storage::fake('logos');
+
+        $logoService = new LogoService();
+        $icon = $logoService->getIcon('twitter');
 
         $this->assertNotNull($icon);
+        Storage::disk('icons')->assertExists($icon);
+    }
+
+    /**
+     * @test
+     */
+    public function test_getIcon_returns_null_when_github_request_fails()
+    {
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response('not found', 404),
+        ]);
+
+        Storage::fake('icons');
+        Storage::fake('logos');
+        $logoService = new LogoService();
+
+        $icon = $logoService->getIcon('twitter');
+
+        $this->assertEquals(null, $icon);
+    }
+
+    /**
+     * @test
+     */
+    public function test_getIcon_returns_null_when_logo_fetching_fails()
+    {
+        $tfaJsonBody = HttpRequestTestData::TFA_JSON_BODY;
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://2fa.directory/api/v3/tfa.json' => Http::response($tfaJsonBody, 200),
+        ]);
+
+        Storage::fake('icons');
+        Storage::fake('logos');
+        $logoService = new LogoService();
+
+        $icon = $logoService->getIcon('twitter');
+
+        $this->assertEquals(null, $icon);
     }
 
     /**
@@ -44,15 +94,32 @@ class LogoServiceTest extends TestCase
      */
     public function test_getIcon_returns_null_when_no_logo_exists()
     {
-        $logoServiceMock = $this->partialMock(LogoService::class, function (MockInterface $mock) {
-            $mock->shouldAllowMockingProtectedMethods()
-            ->shouldReceive('getLogo')
-            ->once()
-            ->andReturn(null);
-        });
+        $logoService = new LogoService();
 
-        $icon = $logoServiceMock->getIcon('no_logo_should_exists_with_this_name');
+        $icon = $logoService->getIcon('no_logo_should_exists_with_this_name');
 
         $this->assertEquals(null, $icon);
     }
+
+    /**
+     * @test
+     */
+    public function test_logoService_loads_empty_collection_when_tfajson_fetching_fails()
+    {
+        $svgLogo = HttpRequestTestData::SVG_LOGO_BODY;
+
+        Http::preventStrayRequests();
+        Http::fake([
+            'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response($svgLogo, 200),
+        ]);
+
+        Storage::fake('icons');
+        Storage::fake('logos');
+
+        $logoService = new LogoService();
+        $icon = $logoService->getIcon('twitter');
+
+        $this->assertNull($icon);
+        Storage::disk('logos')->assertMissing(LogoService::TFA_JSON);
+    }
 }

+ 2 - 1
tests/Feature/Services/QrCodeServiceTest.php

@@ -8,6 +8,7 @@ use Tests\FeatureTestCase;
 
 /**
  * @covers \App\Services\QrCodeService
+ * @covers \App\Facades\QrCode
  */
 class QrCodeServiceTest extends FeatureTestCase
 {
@@ -20,7 +21,7 @@ class QrCodeServiceTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
     }

+ 138 - 0
tests/Feature/Services/ReleaseRadarServiceTest.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace Tests\Feature\Services;
+
+use App\Facades\Settings;
+use App\Services\ReleaseRadarService;
+use Illuminate\Foundation\Testing\WithoutMiddleware;
+use Tests\FeatureTestCase;
+use Illuminate\Support\Facades\Http;
+use Tests\Data\HttpRequestTestData;
+
+/**
+ * @covers \App\Services\ReleaseRadarService
+ */
+class ReleaseRadarServiceTest extends FeatureTestCase
+{
+    use WithoutMiddleware;
+
+    /**
+     * @test
+     */
+    public function test_manualScan_returns_no_new_release()
+    {
+        $url = config('2fauth.latestReleaseUrl');
+
+        Http::preventStrayRequests();
+        Http::fake([
+            $url => Http::response(HttpRequestTestData::LATEST_RELEASE_BODY_NO_NEW_RELEASE, 200),
+        ]);
+
+        $releaseRadarService = new ReleaseRadarService();
+        $release = $releaseRadarService->manualScan();
+
+        $this->assertFalse($release);
+        $this->assertDatabaseHas('options', [
+            'key'   => 'lastRadarScan',
+        ]);
+        $this->assertDatabaseMissing('options', [
+            'key' => 'latestRelease',
+            'value' => HttpRequestTestData::TAG_NAME
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_manualScan_returns_new_release()
+    {
+        $url = config('2fauth.latestReleaseUrl');
+
+        Http::preventStrayRequests();
+        Http::fake([
+            $url => Http::response(HttpRequestTestData::LATEST_RELEASE_BODY_NEW_RELEASE, 200),
+        ]);
+
+        $releaseRadarService = new ReleaseRadarService();
+        $release = $releaseRadarService->manualScan();
+
+        $this->assertEquals(HttpRequestTestData::NEW_TAG_NAME, $release);
+        $this->assertDatabaseHas('options', [
+            'key'   => 'latestRelease',
+            'value' => HttpRequestTestData::NEW_TAG_NAME
+        ]);
+        $this->assertDatabaseHas('options', [
+            'key'   => 'lastRadarScan',
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function test_manualScan_succeed_when_something_fails()
+    {
+        $url = config('2fauth.latestReleaseUrl');
+
+        // We do not fake the http request so an exception will be thrown
+        Http::preventStrayRequests();
+
+        $releaseRadarService = new ReleaseRadarService();
+        $release = $releaseRadarService->manualScan();
+
+        $this->assertFalse($release);
+    }
+
+    /**
+     * @test
+     */
+    public function test_manualScan_succeed_when_github_is_unreachable()
+    {
+        $url = config('2fauth.latestReleaseUrl');
+
+        Http::preventStrayRequests();
+        Http::fake([
+            $url => Http::response(null, 400),
+        ]);
+
+        $releaseRadarService = new ReleaseRadarService();
+        $release = $releaseRadarService->manualScan();
+
+        $this->assertFalse($release);
+    }
+
+    /**
+     * @test
+     */
+    public function test_scheduleScan_runs_after_one_week()
+    {
+        $url = config('2fauth.latestReleaseUrl');
+
+        Http::preventStrayRequests();
+        Http::fake([
+            $url => Http::response(HttpRequestTestData::LATEST_RELEASE_BODY_NEW_RELEASE, 200),
+        ]);
+
+        Settings::set('lastRadarScan', time() - (60 * 60 * 24 * 7) - 1);
+
+        $releaseRadarService = $this->mock(ReleaseRadarService::class)->makePartial();
+        $releaseRadarService->shouldAllowMockingProtectedMethods()
+            ->shouldReceive('newRelease')
+            ->once();
+
+        $releaseRadarService->scheduledScan();
+    }
+
+    /**
+     * @test
+     */
+    public function test_scheduleScan_does_not_run_before_one_week()
+    {
+        Settings::set('lastRadarScan', time() - (60 * 60 * 24 * 7) + 2);
+
+        $releaseRadarService = $this->mock(ReleaseRadarService::class)->makePartial();
+        $releaseRadarService->shouldAllowMockingProtectedMethods()
+            ->shouldNotReceive('newRelease');
+
+        $releaseRadarService->scheduledScan();
+    }
+}

+ 25 - 2
tests/Feature/Services/SettingServiceTest.php

@@ -10,6 +10,7 @@ use Tests\FeatureTestCase;
 
 /**
  * @covers \App\Services\SettingService
+ * @covers \App\Facades\Settings
  */
 class SettingServiceTest extends FeatureTestCase
 {
@@ -57,7 +58,7 @@ class SettingServiceTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -238,7 +239,7 @@ class SettingServiceTest extends FeatureTestCase
     /**
      * Provide invalid data for validation test
      */
-    public function provideUndecipherableData() : array
+    public function provideUndecipherableData(): array
     {
         return [
             [[
@@ -316,4 +317,26 @@ class SettingServiceTest extends FeatureTestCase
             self::VALUE => self::SETTING_VALUE_STRING,
         ]);
     }
+
+    /**
+     * @test
+     */
+    public function test_isUserDefined_returns_true()
+    {
+        DB::table('options')->insert(
+            [self::KEY => 'showTokenAsDot', self::VALUE => strval(self::SETTING_VALUE_TRUE_TRANSFORMED)]
+        );
+
+        $this->assertTrue(Settings::isUserDefined('showTokenAsDot'));
+    }
+
+    /**
+     * @test
+     */
+    public function test_isUserDefined_returns_false()
+    {
+        DB::table('options')->where(self::KEY, 'showTokenAsDot')->delete();
+
+        $this->assertFalse(Settings::isUserDefined('showTokenAsDot'));
+    }
 }

+ 7 - 5
tests/Feature/Services/TwoFAccountServiceTest.php

@@ -5,11 +5,13 @@ namespace Tests\Feature\Services;
 use App\Facades\TwoFAccounts;
 use App\Models\Group;
 use App\Models\TwoFAccount;
-use Tests\Classes\OtpTestData;
+use Tests\Data\OtpTestData;
 use Tests\FeatureTestCase;
+use Tests\Data\MigrationTestData;
 
 /**
  * @covers \App\Services\TwoFAccountService
+ * @covers \App\Facades\TwoFAccounts
  */
 class TwoFAccountServiceTest extends FeatureTestCase
 {
@@ -31,7 +33,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
     /**
      * @test
      */
-    public function setUp() : void
+    public function setUp(): void
     {
         parent::setUp();
 
@@ -179,7 +181,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
      */
     public function test_convert_migration_from_gauth_returns_correct_accounts()
     {
-        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = TwoFAccounts::migrate(MigrationTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertCount(2, $twofaccounts);
 
@@ -226,7 +228,7 @@ class TwoFAccountServiceTest extends FeatureTestCase
         $twofaccount = new TwoFAccount;
         $twofaccount->fillWithOtpParameters($parameters)->save();
 
-        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI);
+        $twofaccounts = TwoFAccounts::migrate(MigrationTestData::GOOGLE_AUTH_MIGRATION_URI);
 
         $this->assertEquals(-1, $twofaccounts->first()->id);
         $this->assertEquals(-1, $twofaccounts->last()->id);
@@ -238,6 +240,6 @@ class TwoFAccountServiceTest extends FeatureTestCase
     public function test_convert_invalid_migration_from_gauth_returns_InvalidMigrationData_exception()
     {
         $this->expectException(\App\Exceptions\InvalidMigrationDataException::class);
-        $twofaccounts = TwoFAccounts::migrate(OtpTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
+        $twofaccounts = TwoFAccounts::migrate(MigrationTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA);
     }
 }

+ 48 - 10
tests/Unit/Exceptions/HandlerTest.php

@@ -7,6 +7,15 @@ use Illuminate\Contracts\Container\Container;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Tests\TestCase;
+use App\Exceptions\InvalidOtpParameterException;
+use \App\Exceptions\InvalidQrCodeException;
+use App\Exceptions\InvalidSecretException;
+use App\Exceptions\DbEncryptionException;
+use App\Exceptions\InvalidMigrationDataException;
+use App\Exceptions\UndecipherableException;
+use App\Exceptions\UnsupportedMigrationException;
+use App\Exceptions\UnsupportedOtpTypeException;
+use App\Exceptions\EncryptedMigrationException;
 
 /**
  * @covers \App\Exceptions\Handler
@@ -41,32 +50,35 @@ class HandlerTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideExceptionsforBadRequest() : array
+    public function provideExceptionsforBadRequest(): array
     {
         return [
             [
-                '\App\Exceptions\InvalidOtpParameterException',
+                InvalidOtpParameterException::class,
             ],
             [
-                '\App\Exceptions\InvalidQrCodeException',
+                InvalidQrCodeException::class,
             ],
             [
-                '\App\Exceptions\InvalidSecretException',
+                InvalidSecretException::class,
             ],
             [
-                '\App\Exceptions\DbEncryptionException',
+                DbEncryptionException::class,
             ],
             [
-                '\App\Exceptions\InvalidMigrationDataException',
+                InvalidMigrationDataException::class,
             ],
             [
-                '\App\Exceptions\UndecipherableException',
+                UndecipherableException::class,
             ],
             [
-                '\App\Exceptions\UnsupportedMigrationException',
+                UnsupportedMigrationException::class,
             ],
             [
-                '\App\Exceptions\UnsupportedOtpTypeException',
+                UnsupportedOtpTypeException::class,
+            ],
+            [
+                EncryptedMigrationException::class,
             ],
         ];
     }
@@ -99,7 +111,7 @@ class HandlerTest extends TestCase
     /**
      * Provide Valid data for validation test
      */
-    public function provideExceptionsforNotFound() : array
+    public function provideExceptionsforNotFound(): array
     {
         return [
             [
@@ -111,6 +123,32 @@ class HandlerTest extends TestCase
         ];
     }
 
+    /**
+     * @test
+     */
+    public function test_authenticationException_returns_unauthorized_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(\Illuminate\Auth\AuthenticationException::class);
+        $mockException->method('guards')->willReturn(['web-guard']);
+
+        $response = $method->invokeArgs($instance, [$request, $mockException]);
+
+        $this->assertInstanceOf(JsonResponse::class, $response);
+
+        $response = \Illuminate\Testing\TestResponse::fromBaseResponse($response);
+        $response->assertStatus(401)
+            ->assertJsonStructure([
+                'message',
+            ]);
+    }
+
     /**
      * @test
      */

+ 98 - 0
tests/Unit/HelpersTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Helpers\Helpers;
+use Tests\TestCase;
+
+/**
+ * @covers \App\Helpers\Helpers
+ */
+class HelpersTest extends TestCase
+{
+    /**
+     * @test
+     */
+    public function test_getUniqueFilename_returns_filename()
+    {
+        $ext = 'jpg';
+        $filename = Helpers::getUniqueFilename($ext);
+
+        $this->assertIsString($filename);
+        $this->assertStringEndsWith('.' . $ext, $filename);
+        $this->assertEquals(41 + strlen($ext), strlen($filename));
+    }
+
+    /**
+     * @test
+     * 
+     * @dataProvider  versionNumberProvider
+     */
+    public function test_cleanVersionNumber_returns_cleaned_version($dirtyVersion, $expected)
+    {
+        $cleanedVersion = Helpers::cleanVersionNumber($dirtyVersion);
+
+        $this->assertEquals($expected, $cleanedVersion);
+    }
+
+    /**
+     * Provide data for cleanVersionNumber() tests
+     */
+    public function versionNumberProvider()
+    {
+        return [
+            [
+                'v3.2.1',
+                '3.2.1',
+            ],
+            [
+                'v3.2.1-beta',
+                '3.2.1-beta',
+            ],
+            [
+                'v3.0.1-alpha+001',
+                '3.0.1-alpha+001',
+            ],
+            [
+                'version03.0.1 alpha+001',
+                '3.0.1',
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     * 
+     * @dataProvider  invalidVersionNumberProvider
+     */
+    public function test_cleanVersionNumber_returns_false_with_invalid_semver($dirtyVersion)
+    {
+        $cleanedVersion = Helpers::cleanVersionNumber($dirtyVersion);
+
+        $this->assertEquals(false, $cleanedVersion);
+    }
+
+    /**
+     * Provide data for cleanVersionNumber() tests
+     */
+    public function invalidVersionNumberProvider()
+    {
+        return [
+            [
+                'v3.2.',
+            ],
+            [
+                'v3..1-beta',
+            ],
+            [
+                'v.0.1-alpha+001',
+            ],
+            [
+                '3.00.1 alpha+001',
+            ],
+            [
+                '3.00.1 alpha+001',
+            ],
+        ];
+    }
+}

+ 6 - 0
tests/Unit/Listeners/CleanIconStorageTest.php

@@ -16,6 +16,9 @@ use Tests\TestCase;
  */
 class CleanIconStorageTest extends TestCase
 {
+    /**
+     * @test
+     */
     public function test_it_deletes_icon_file_on_twofaccount_deletion()
     {
         $settingService = $this->mock(SettingService::class, function (MockInterface $settingService) {
@@ -34,6 +37,9 @@ class CleanIconStorageTest extends TestCase
         $this->assertNull($listener->handle($event));
     }
 
+    /**
+     * @test
+     */
     public function test_CleanIconStorage_listen_to_TwoFAccountDeleted_event()
     {
         Event::fake();

+ 25 - 7
tests/Unit/Listeners/DissociateTwofaccountFromGroupTest.php

@@ -5,23 +5,41 @@ namespace Tests\Unit\Listeners;
 use App\Events\GroupDeleting;
 use App\Listeners\DissociateTwofaccountFromGroup;
 use App\Models\Group;
+use App\Models\TwoFAccount;
 use Illuminate\Support\Facades\Event;
 use Tests\TestCase;
+use Mockery\MockInterface;
 
 /**
  * @covers \App\Listeners\DissociateTwofaccountFromGroup
  */
 class DissociateTwofaccountFromGroupTest extends TestCase
 {
-    // public function test_twofaccount_is_released_on_group_deletion()
-    // {
-    //     $group = Group::factory()->make();
-    //     $event = new GroupDeleting($group);
-    //     $listener = new DissociateTwofaccountFromGroup();
+    /**
+     * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
+     */
+    public function test_twofaccount_is_released_on_group_deletion()
+    {
+
+        $this->mock('alias:' . TwoFAccount::class, function (MockInterface $twoFAccount) {
+            $twoFAccount->shouldReceive('where->update')
+                ->once()
+                ->andReturn(1);
+        });
 
-    //     $this->assertNull($listener->handle($event));
-    // }
+        $group = Group::factory()->make();
+        $event = new GroupDeleting($group);
+        $listener = new DissociateTwofaccountFromGroup();
+
+        $this->assertNull($listener->handle($event));
+    }
 
+    /**
+     * @test
+     */
     public function test_DissociateTwofaccountFromGroup_listen_to_groupDeleting_event()
     {
         Event::fake();

+ 484 - 0
tests/Unit/MigratorTest.php

@@ -0,0 +1,484 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Exceptions\EncryptedMigrationException;
+use App\Factories\MigratorFactory;
+use App\Exceptions\InvalidMigrationDataException;
+use App\Models\TwoFAccount;
+use App\Services\Migrators\AegisMigrator;
+use App\Services\Migrators\TwoFASMigrator;
+use App\Services\Migrators\Migrator;
+use App\Services\Migrators\PlainTextMigrator;
+use App\Services\Migrators\GoogleAuthMigrator;
+use App\Services\SettingService;
+use Illuminate\Support\Facades\Storage;
+use Mockery;
+use Mockery\Mock;
+use Mockery\MockInterface;
+use Tests\Data\MigrationTestData;
+use Tests\Data\OtpTestData;
+use Tests\TestCase;
+use ParagonIE\ConstantTime\Base32;
+use App\Protobuf\GoogleAuth\Payload\Algorithm;
+use App\Exceptions\UnsupportedMigrationException;
+
+
+/**
+ * @covers \App\Providers\MigrationServiceProvider
+ * @covers \App\Factories\MigratorFactory
+ * @covers \App\Services\Migrators\Migrator
+ * @covers \App\Services\Migrators\AegisMigrator
+ * @covers \App\Services\Migrators\TwoFASMigrator
+ * @covers \App\Services\Migrators\PlainTextMigrator
+ * @covers \App\Services\Migrators\GoogleAuthMigrator
+ * @uses \App\Models\TwoFAccount
+ */
+class MigratorTest extends TestCase
+{
+    /**
+     * App\Models\TwoFAccount $totpTwofaccount
+     */
+    protected $totpTwofaccount;
+
+    /**
+     * App\Models\TwoFAccount $totpTwofaccount
+     */
+    protected $hotpTwofaccount;
+
+    /**
+     * App\Models\TwoFAccount $steamTwofaccount
+     */
+    protected $steamTwofaccount;
+
+    /**
+     * App\Models\TwoFAccount $GAuthTotpTwofaccount
+     */
+    protected $GAuthTotpTwofaccount;
+
+    /**
+     * App\Models\TwoFAccount $GAuthTotpBisTwofaccount
+     */
+    protected $GAuthTotpBisTwofaccount;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->mock(SettingService::class, function (MockInterface $settingService) {
+            $settingService->allows()
+                ->get('useEncryption')
+                ->andReturn(false);
+
+            $settingService->allows()
+                ->get('getOfficialIcons')
+                ->andReturn(false);
+        });
+
+        $this->totpTwofaccount             = new TwoFAccount;
+        $this->totpTwofaccount->legacy_uri = OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG;
+        $this->totpTwofaccount->service    = OtpTestData::SERVICE;
+        $this->totpTwofaccount->account    = OtpTestData::ACCOUNT;
+        $this->totpTwofaccount->icon       = null;
+        $this->totpTwofaccount->otp_type   = 'totp';
+        $this->totpTwofaccount->secret     = OtpTestData::SECRET;
+        $this->totpTwofaccount->digits     = OtpTestData::DIGITS_CUSTOM;
+        $this->totpTwofaccount->algorithm  = OtpTestData::ALGORITHM_CUSTOM;
+        $this->totpTwofaccount->period     = OtpTestData::PERIOD_CUSTOM;
+        $this->totpTwofaccount->counter    = null;
+
+        $this->hotpTwofaccount             = new TwoFAccount;
+        $this->hotpTwofaccount->legacy_uri = OtpTestData::HOTP_FULL_CUSTOM_URI_NO_IMG;
+        $this->hotpTwofaccount->service    = OtpTestData::SERVICE;
+        $this->hotpTwofaccount->account    = OtpTestData::ACCOUNT;
+        $this->hotpTwofaccount->icon       = null;
+        $this->hotpTwofaccount->otp_type   = 'hotp';
+        $this->hotpTwofaccount->secret     = OtpTestData::SECRET;
+        $this->hotpTwofaccount->digits     = OtpTestData::DIGITS_CUSTOM;
+        $this->hotpTwofaccount->algorithm  = OtpTestData::ALGORITHM_CUSTOM;
+        $this->hotpTwofaccount->period     = null;
+        $this->hotpTwofaccount->counter    = OtpTestData::COUNTER_CUSTOM;
+
+        $this->steamTwofaccount             = new TwoFAccount;
+        $this->steamTwofaccount->legacy_uri = OtpTestData::STEAM_TOTP_URI;
+        $this->steamTwofaccount->service    = OtpTestData::STEAM;
+        $this->steamTwofaccount->account    = OtpTestData::ACCOUNT;
+        $this->steamTwofaccount->icon       = null;
+        $this->steamTwofaccount->otp_type   = 'steamtotp';
+        $this->steamTwofaccount->secret     = OtpTestData::STEAM_SECRET;
+        $this->steamTwofaccount->digits     = OtpTestData::DIGITS_STEAM;
+        $this->steamTwofaccount->algorithm  = OtpTestData::ALGORITHM_DEFAULT;
+        $this->steamTwofaccount->period     = OtpTestData::PERIOD_DEFAULT;
+        $this->steamTwofaccount->counter    = null;
+
+        $this->GAuthTotpTwofaccount             = new TwoFAccount;
+        $this->GAuthTotpTwofaccount->service    = OtpTestData::SERVICE;
+        $this->GAuthTotpTwofaccount->account    = OtpTestData::ACCOUNT;
+        $this->GAuthTotpTwofaccount->icon       = null;
+        $this->GAuthTotpTwofaccount->otp_type   = 'totp';
+        $this->GAuthTotpTwofaccount->secret     = OtpTestData::SECRET;
+        $this->GAuthTotpTwofaccount->digits     = OtpTestData::DIGITS_DEFAULT;
+        $this->GAuthTotpTwofaccount->algorithm  = OtpTestData::ALGORITHM_DEFAULT;
+        $this->GAuthTotpTwofaccount->period     = OtpTestData::PERIOD_DEFAULT;
+        $this->GAuthTotpTwofaccount->counter    = null;
+
+        $this->GAuthTotpBisTwofaccount             = new TwoFAccount;
+        $this->GAuthTotpBisTwofaccount->service    = OtpTestData::SERVICE . '_bis';
+        $this->GAuthTotpBisTwofaccount->account    = OtpTestData::ACCOUNT . '_bis';
+        $this->GAuthTotpBisTwofaccount->icon       = null;
+        $this->GAuthTotpBisTwofaccount->otp_type   = 'totp';
+        $this->GAuthTotpBisTwofaccount->secret     = OtpTestData::SECRET;
+        $this->GAuthTotpBisTwofaccount->digits     = OtpTestData::DIGITS_DEFAULT;
+        $this->GAuthTotpBisTwofaccount->algorithm  = OtpTestData::ALGORITHM_DEFAULT;
+        $this->GAuthTotpBisTwofaccount->period     = OtpTestData::PERIOD_DEFAULT;
+        $this->GAuthTotpBisTwofaccount->counter    = null;
+
+        $this->fakeTwofaccount             = new TwoFAccount;
+        $this->fakeTwofaccount->id         = TwoFAccount::FAKE_ID;
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider validMigrationsProvider
+     */
+    public function test_migrate_returns_consistent_accounts(Migrator $migrator, mixed $payload, string $expected, bool $hasSteam)
+    {
+        $accounts = $migrator->migrate($payload);
+
+        if ($expected === 'gauth') {
+            $totp = $this->GAuthTotpTwofaccount;
+            $hotp = $this->GAuthTotpBisTwofaccount;
+        } else {
+            $totp = $this->totpTwofaccount;
+            $hotp = $this->hotpTwofaccount;
+            if ($hasSteam) {
+                $steam = $this->steamTwofaccount;
+            }
+        }
+
+        $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
+        $this->assertCount($hasSteam ? 3 : 2, $accounts);
+
+        // The returned collection could have non-linear index (because of possible blank lines
+        // in the migration payload) so we do not use get() to retrieve items
+        $this->assertObjectEquals($totp, $accounts->first());
+        $this->assertObjectEquals($hotp, $accounts->slice(1, 1)->first());
+        if ($hasSteam) {
+            $this->assertObjectEquals($steam, $accounts->last());
+        }
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function validMigrationsProvider()
+    {
+        return [
+            'PLAIN_TEXT_PAYLOAD' => [
+                new PlainTextMigrator(),
+                MigrationTestData::VALID_PLAIN_TEXT_PAYLOAD,
+                'custom',
+                $hasSteam = true
+            ],
+            'PLAIN_TEXT_PAYLOAD_WITH_INTRUDER' => [
+                new PlainTextMigrator(),
+                MigrationTestData::VALID_PLAIN_TEXT_PAYLOAD_WITH_INTRUDER,
+                'custom',
+                $hasSteam = true
+            ],
+            'AEGIS_JSON_MIGRATION_PAYLOAD' => [
+                new AegisMigrator(),
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD,
+                'custom',
+                $hasSteam = true
+            ],
+            '2FAS_MIGRATION_PAYLOAD' => [
+                new TwoFASMigrator(),
+                MigrationTestData::VALID_2FAS_MIGRATION_PAYLOAD,
+                'custom',
+                $hasSteam = false
+            ],
+            'GOOGLE_AUTH_MIGRATION_PAYLOAD' => [
+                new GoogleAuthMigrator(),
+                MigrationTestData::GOOGLE_AUTH_MIGRATION_URI,
+                'gauth',
+                $hasSteam = false,
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider invalidMigrationsProvider
+     */
+    public function test_migrate_with_invalid_payload_returns_InvalidMigrationDataException(Migrator $migrator, mixed $payload)
+    {
+        $this->expectException(InvalidMigrationDataException::class);
+
+        $accounts = $migrator->migrate($payload);
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function invalidMigrationsProvider()
+    {
+        return [
+            'INVALID_PLAIN_TEXT_NO_URI' => [
+                new PlainTextMigrator(),
+                MigrationTestData::INVALID_PLAIN_TEXT_NO_URI,
+            ],
+            'INVALID_PLAIN_TEXT_ONLY_EMPTY_LINES' => [
+                new PlainTextMigrator(),
+                MigrationTestData::INVALID_PLAIN_TEXT_ONLY_EMPTY_LINES,
+            ],
+            'INVALID_PLAIN_TEXT_NULL' => [
+                new PlainTextMigrator(),
+                null,
+            ],
+            'INVALID_PLAIN_TEXT_EMPTY_STRING' => [
+                new PlainTextMigrator(),
+                '',
+            ],
+            'INVALID_PLAIN_TEXT_INT' => [
+                new PlainTextMigrator(),
+                10,
+            ],
+            'INVALID_PLAIN_TEXT_BOOL' => [
+                new PlainTextMigrator(),
+                true,
+            ],
+            'INVALID_AEGIS_JSON_MIGRATION_PAYLOAD' => [
+                new AegisMigrator(),
+                MigrationTestData::INVALID_AEGIS_JSON_MIGRATION_PAYLOAD,
+            ],
+            'ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD' => [
+                new AegisMigrator(),
+                MigrationTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD,
+            ],
+            'INVALID_2FAS_MIGRATION_PAYLOAD' => [
+                new TwoFASMigrator(),
+                MigrationTestData::INVALID_2FAS_MIGRATION_PAYLOAD,
+            ],
+            'INVALID_GOOGLE_AUTH_MIGRATION_URI' => [
+                new GoogleAuthMigrator(),
+                MigrationTestData::INVALID_GOOGLE_AUTH_MIGRATION_URI,
+            ],
+            'GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA' => [
+                new GoogleAuthMigrator(),
+                MigrationTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA,
+            ],
+
+        ];
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider migrationWithInvalidAccountsProvider
+     */
+    public function test_migrate_returns_fake_accounts(Migrator $migrator, mixed $payload)
+    {
+        $accounts = $migrator->migrate($payload);
+
+        $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
+        $this->assertCount(2, $accounts);
+
+        // The returned collection could have non-linear index (because of possible blank lines
+        // in the migration payload) so we do not use get() to retrieve items
+        $this->assertObjectEquals($this->totpTwofaccount, $accounts->first());
+        $this->assertEquals($this->fakeTwofaccount->id, $accounts->last()->id);
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function migrationWithInvalidAccountsProvider()
+    {
+        return [
+            'PLAIN_TEXT_PAYLOAD_WITH_INVALID_URI' => [
+                new PlainTextMigrator(),
+                MigrationTestData::PLAIN_TEXT_PAYLOAD_WITH_INVALID_URI,
+            ],
+            'VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE' => [
+                new AegisMigrator(),
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE,
+            ],
+            'VALID_2FAS_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE' => [
+                new TwoFASMigrator(),
+                MigrationTestData::VALID_2FAS_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE,
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     * 
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
+     */
+    public function test_migrate_gauth_returns_fake_accounts()
+    {
+        $this->mock('alias:' . Base32::class, function (MockInterface $baseEncoder) {
+            $baseEncoder->shouldReceive('encodeUpper')
+                ->andThrow(new \Exception());
+        });
+
+        $migrator = new GoogleAuthMigrator();
+        $accounts = $migrator->migrate(MigrationTestData::GOOGLE_AUTH_MIGRATION_URI);
+
+        $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
+        $this->assertCount(2, $accounts);
+
+        // The returned collection could have non-linear index (because of possible blank lines
+        // in the migration payload) so we do not use get() to retrieve items
+        $this->assertEquals($this->fakeTwofaccount->id, $accounts->first()->id);
+        $this->assertEquals($this->fakeTwofaccount->id, $accounts->last()->id);
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider AegisWithIconMigrationProvider
+     */
+    public function test_migrate_aegis_payload_with_icon_sets_and_stores_the_icon($migration)
+    {
+        Storage::fake('icons');
+
+        $migrator = new AegisMigrator();
+        $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 AegisWithIconMigrationProvider()
+    {
+        return [
+            'SVG' => [
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON,
+            ],
+            'PNG' => [
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON,
+            ],
+            'JPG' => [
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON,
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     */
+    public function test_migrate_aegis_payload_with_unsupported_icon_does_not_fail()
+    {
+        Storage::fake('icons');
+
+        $migrator = new AegisMigrator();
+        $accounts = $migrator->migrate(MigrationTestData::VALID_AEGIS_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)
+    {
+        $factory = new MigratorFactory();
+
+        $migrator = $factory->create($payload);
+
+        $this->assertInstanceOf($migratorClass, $migrator);
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function factoryProvider()
+    {
+        return [
+            'VALID_PLAIN_TEXT_PAYLOAD' => [
+                MigrationTestData::VALID_PLAIN_TEXT_PAYLOAD,
+                PlainTextMigrator::class,
+            ],
+            'VALID_AEGIS_JSON_MIGRATION_PAYLOAD' => [
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD,
+                AegisMigrator::class,
+            ],
+            'VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON' => [
+                MigrationTestData::VALID_AEGIS_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON,
+                AegisMigrator::class,
+            ],
+            'VALID_2FAS_MIGRATION_PAYLOAD' => [
+                MigrationTestData::VALID_2FAS_MIGRATION_PAYLOAD,
+                TwoFASMigrator::class,
+            ],
+            'GOOGLE_AUTH_MIGRATION_URI' => [
+                MigrationTestData::GOOGLE_AUTH_MIGRATION_URI,
+                GoogleAuthMigrator::class,
+            ],
+        ];
+    }
+
+    /**
+     * @test
+     */
+    public function test_factory_throw_UnsupportedMigrationException()
+    {
+        $this->expectException(UnsupportedMigrationException::class);
+        $factory = new MigratorFactory();
+
+        $migrator = $factory->create('not_a_valid_payload');
+    }
+
+    /**
+     * @test
+     *
+     * @dataProvider encryptedMigrationDataProvider
+     */
+    public function test_factory_throw_EncryptedMigrationException($payload)
+    {
+        $this->expectException(EncryptedMigrationException::class);
+
+        $factory = new MigratorFactory();
+
+        $migrator = $factory->create($payload);
+    }
+
+    /**
+     * Provide data for TwoFAccount store tests
+     */
+    public function encryptedMigrationDataProvider()
+    {
+        return [
+            'ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD' => [
+                MigrationTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD
+            ],
+            'ENCRYPTED_2FAS_MIGRATION_PAYLOAD' => [
+                MigrationTestData::ENCRYPTED_2FAS_MIGRATION_PAYLOAD
+            ],
+        ];
+    }
+
+    /**
+     * 
+     */
+    protected function tearDown(): void
+    {
+        Mockery::close();
+    }
+}

部分文件因为文件数量过多而无法显示