2FAuth/tests/Unit/MigratorTest.php
2024-10-27 08:16:48 +01:00

551 lines
20 KiB
PHP

<?php
namespace Tests\Unit;
use App\Exceptions\EncryptedMigrationException;
use App\Exceptions\InvalidMigrationDataException;
use App\Exceptions\UnsupportedMigrationException;
use App\Facades\Icons;
use App\Facades\IconStore;
use App\Factories\MigratorFactory;
use App\Models\TwoFAccount;
use App\Providers\MigrationServiceProvider;
use App\Providers\TwoFAuthServiceProvider;
use App\Services\Migrators\AegisMigrator;
use App\Services\Migrators\GoogleAuthMigrator;
use App\Services\Migrators\Migrator;
use App\Services\Migrators\PlainTextMigrator;
use App\Services\Migrators\TwoFASMigrator;
use App\Services\Migrators\TwoFAuthMigrator;
use App\Services\SettingService;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Data\MigrationTestData;
use Tests\Data\OtpTestData;
use Tests\TestCase;
/**
* MigratorTest test class
*/
#[CoversClass(MigrationServiceProvider::class)]
#[CoversClass(TwoFAuthServiceProvider::class)]
#[CoversClass(MigratorFactory::class)]
#[CoversClass(Migrator::class)]
#[CoversClass(AegisMigrator::class)]
#[CoversClass(TwoFASMigrator::class)]
#[CoversClass(PlainTextMigrator::class)]
#[CoversClass(GoogleAuthMigrator::class)]
#[CoversClass(TwoFAuthMigrator::class)]
#[UsesClass(TwoFAccount::class)]
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;
protected $fakeTwofaccount;
public function setUp() : void
{
parent::setUp();
$this->mock(SettingService::class, function (MockInterface $iconStore) {
foreach (config('2fauth.settings') as $setting => $value) {
$iconStore->shouldReceive('get')
->with($setting)
->andReturn($value);
}
});
$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;
}
/**
* Clean up the testing environment before the next test.
*
*
* @throws \Mockery\Exception\InvalidCountException
*/
protected function tearDown() : void
{
$this->forgetMock(SettingService::class);
parent::tearDown();
}
#[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 static 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,
],
'2FAUTH_MIGRATION_PAYLOAD' => [
new TwoFAuthMigrator,
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
'custom',
$hasSteam = true,
],
];
}
#[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 static 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,
],
'INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD' => [
new TwoFAuthMigrator,
MigrationTestData::INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
],
];
}
#[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 static 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,
],
'VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE' => [
new TwoFAuthMigrator,
MigrationTestData::VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE,
],
];
}
#[Test]
public function test_migrate_gauth_returns_fake_accounts()
{
$migrator = $this->partialMock(GoogleAuthMigrator::class, function (MockInterface $migrator) {
$migrator->shouldAllowMockingProtectedMethods()->shouldReceive('toBase32')
->andThrow(new \Exception);
});
/** @disregard Undefined function */
$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);
$this->forgetMock(GoogleAuthMigrator::class);
}
#[Test]
#[DataProvider('AegisWithIconMigrationProvider')]
public function test_migrate_aegis_payload_with_icon_sets_and_stores_the_icon($migration)
{
Icons::spy();
Storage::fake('icons');
$migrator = new AegisMigrator;
$accounts = $migrator->migrate($migration);
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
$this->assertCount(1, $accounts);
Icons::shouldHaveReceived('buildFromResource')->once();
Storage::disk('icons')->assertExists($accounts->first()->icon);
}
/**
* Provide data for TwoFAccount store tests
*/
public static 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('TwoFAuthWithIconMigrationProvider')]
public function test_migrate_2fauth_payload_with_icon_sets_and_stores_the_icon($migration)
{
Icons::spy();
Storage::fake('icons');
$migrator = new TwoFAuthMigrator;
$accounts = $migrator->migrate($migration);
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
$this->assertCount(1, $accounts);
Icons::shouldHaveReceived('buildFromResource')->once();
Storage::disk('icons')->assertExists($accounts->first()->icon);
}
/**
* Provide data for TwoFAccount store tests
*/
public static function TwoFAuthWithIconMigrationProvider()
{
return [
'SVG' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON,
],
'PNG' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON,
],
'JPG' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON,
],
'BMP' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_BMP_ICON,
],
'XBMP' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_XBMP_ICON,
],
'WEBP' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_WEBP_ICON,
],
];
}
#[Test]
public function test_migrate_2fauth_payload_with_unsupported_icon_does_not_fail()
{
Storage::fake('icons');
$migrator = new TwoFAuthMigrator;
$accounts = $migrator->migrate(MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON);
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
$this->assertCount(1, $accounts);
$this->assertNull($this->fakeTwofaccount->icon);
Storage::disk('icons')->assertDirectoryEmpty('/');
}
#[Test]
#[DataProvider('factoryProvider')]
public function test_factory_returns_relevant_migrator($payload, $migratorClass)
{
$factory = new MigratorFactory;
$migrator = $factory->create($payload);
$this->assertInstanceOf($migratorClass, $migrator);
}
/**
* Provide data for TwoFAccount store tests
*/
public static 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,
],
'2FAUTH_MIGRATION_URI' => [
MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD,
TwoFAuthMigrator::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 static function encryptedMigrationDataProvider()
{
return [
'ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD' => [
MigrationTestData::ENCRYPTED_AEGIS_JSON_MIGRATION_PAYLOAD,
],
'ENCRYPTED_2FAS_MIGRATION_PAYLOAD' => [
MigrationTestData::ENCRYPTED_2FAS_MIGRATION_PAYLOAD,
],
];
}
}