Add Store icons to database feature

This commit is contained in:
Bubka 2024-10-18 14:28:45 +02:00
parent 51d6a6c649
commit f009b31a68
59 changed files with 3268 additions and 839 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,11 +3,14 @@
namespace App\Api\v1\Controllers; namespace App\Api\v1\Controllers;
use App\Api\v1\Requests\IconFetchRequest; use App\Api\v1\Requests\IconFetchRequest;
use App\Facades\IconStore;
use App\Helpers\Helpers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Services\LogoService; use App\Services\LogoService;
use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Http\UploadedFile;
class IconController extends Controller class IconController extends Controller
{ {
@ -22,11 +25,21 @@ public function upload(Request $request)
'icon' => 'required|image', 'icon' => 'required|image',
]); ]);
$icon = $request->file('icon'); $icon = $request->file('icon');
$path = $icon instanceof \Illuminate\Http\UploadedFile ? $icon->store('', 'icons') : false; $isStored = $name = false;
return $path if ($icon instanceof UploadedFile) {
? response()->json(['filename' => pathinfo($path)['basename']], 201) try {
if ($content = $icon->get()) {
$name = Helpers::getRandomFilename($icon->extension());
$isStored = IconStore::store($name, $content);
}
}
catch (Exception) { }
}
return $isStored
? response()->json(['filename' => $name], 201)
: response()->json(['message' => __('errors.file_upload_failed')], 500); : response()->json(['message' => __('errors.file_upload_failed')], 500);
} }
@ -58,7 +71,7 @@ public function delete(string $icon, Request $request)
abort(403, 'unauthorized'); abort(403, 'unauthorized');
} }
Storage::disk('icons')->delete($icon); IconStore::delete($icon);
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@ -56,6 +56,9 @@ public function show(TwoFAccount $twofaccount)
{ {
$this->authorize('view', $twofaccount); $this->authorize('view', $twofaccount);
// $icon = $twofaccount->icon;
// $iconRes = $twofaccount->icon()->get();
return new TwoFAccountReadResource($twofaccount); return new TwoFAccountReadResource($twofaccount);
} }

View File

@ -2,6 +2,7 @@
namespace App\Api\v1\Resources; namespace App\Api\v1\Resources;
use App\Facades\IconStore;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -33,8 +34,8 @@ public function toArray($request)
'account' => $this->account, 'account' => $this->account,
'service' => $this->service, 'service' => $this->service,
'icon' => $this->icon, 'icon' => $this->icon,
'icon_mime' => $this->icon ? Storage::disk('icons')->mimeType((string) $this->icon) : null, 'icon_mime' => $this->icon && IconStore::exists($this->icon) ? IconStore::mimeType($this->icon) : null,
'icon_file' => $this->icon ? base64_encode(Storage::disk('icons')->get((string) $this->icon)) : null, 'icon_file' => $this->icon && IconStore::exists($this->icon) ? base64_encode(IconStore::get($this->icon)) : null,
'secret' => $this->secret, 'secret' => $this->secret,
'digits' => (int) $this->digits, 'digits' => (int) $this->digits,
'algorithm' => $this->algorithm, 'algorithm' => $this->algorithm,

View File

@ -5,7 +5,7 @@
/** /**
* @property mixed $id * @property mixed $id
* @property mixed $group_id * @property mixed $group_id
* *
* @method App\Models\Dto\TotpDto|App\Models\Dto\HotpDto getOtp(int $time) * @method App\Models\Dto\TotpDto|App\Models\Dto\HotpDto getOtp(int $time)
*/ */
class TwoFAccountReadResource extends TwoFAccountStoreResource class TwoFAccountReadResource extends TwoFAccountStoreResource

View File

@ -2,6 +2,7 @@
namespace App\Api\v1\Resources; namespace App\Api\v1\Resources;
use App\Facades\IconStore;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
@ -29,7 +30,7 @@ public function toArray($request)
'otp_type' => $this->otp_type, 'otp_type' => $this->otp_type,
'account' => $this->account, 'account' => $this->account,
'service' => $this->service, 'service' => $this->service,
'icon' => $this->icon, 'icon' => $this->icon && IconStore::exists($this->icon) ? $this->icon : null,
'secret' => $this->when( 'secret' => $this->when(
! $request->has('withSecret') || (int) filter_var($request->input('withSecret'), FILTER_VALIDATE_BOOLEAN) == 1, ! $request->has('withSecret') || (int) filter_var($request->input('withSecret'), FILTER_VALIDATE_BOOLEAN) == 1,
$this->secret $this->secret

View File

@ -2,8 +2,9 @@
namespace App\Console\Commands\Utils; namespace App\Console\Commands\Utils;
use App\Facades\IconStore;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
trait ResetTrait trait ResetTrait
{ {
@ -21,8 +22,7 @@ protected function resetIcons() : void
*/ */
protected function deleteIcons() : void protected function deleteIcons() : void
{ {
$filesForDelete = \Illuminate\Support\Facades\File::glob('public/icons/*.png'); IconStore::clear();
Storage::delete($filesForDelete);
$this->line('Existing icons deleted'); $this->line('Existing icons deleted');
} }
@ -32,15 +32,20 @@ protected function deleteIcons() : void
*/ */
protected function generateIcons() : void protected function generateIcons() : void
{ {
IconGenerator::generateIcon('amazon', IconGenerator::AMAZON); $icons = collect();
IconGenerator::generateIcon('apple', IconGenerator::APPLE); $icons->push(['amazon.png', base64_decode(DemoIcons::AMAZON)]);
IconGenerator::generateIcon('dropbox', IconGenerator::DROPBOX); $icons->push(['apple.png', base64_decode(DemoIcons::APPLE)]);
IconGenerator::generateIcon('facebook', IconGenerator::FACEBOOK); $icons->push(['dropbox.png', base64_decode(DemoIcons::DROPBOX)]);
IconGenerator::generateIcon('github', IconGenerator::GITHUB); $icons->push(['facebook.png', base64_decode(DemoIcons::FACEBOOK)]);
IconGenerator::generateIcon('google', IconGenerator::GOOGLE); $icons->push(['github.png', base64_decode(DemoIcons::GITHUB)]);
IconGenerator::generateIcon('instagram', IconGenerator::INSTAGRAM); $icons->push(['google.png', base64_decode(DemoIcons::GOOGLE)]);
IconGenerator::generateIcon('linkedin', IconGenerator::LINKEDIN); $icons->push(['instagram.png', base64_decode(DemoIcons::INSTAGRAM)]);
IconGenerator::generateIcon('twitter', IconGenerator::TWITTER); $icons->push(['linkedin.png', base64_decode(DemoIcons::LINKEDIN)]);
$icons->push(['twitter.png', base64_decode(DemoIcons::TWITTER)]);
$icons->each(function (array $icon) {
IconStore::store($icon[0], $icon[1]);
});
$this->line('Icons regenerated'); $this->line('Icons regenerated');
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Support\Facades\Log;
class storeIconsInDatabaseSettingChanged
{
use Dispatchable;
/**
* The new value of setting storeIconsInDatabase.
*/
public bool $newValue;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(bool $newValue)
{
$this->newValue = $newValue;
Log::info('storeIconsInDatabaseSettingChanged event dispatched');
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Class FailedIconStoreDatabaseTogglingException.
*
* @codeCoverageIgnore
*/
class FailedIconStoreDatabaseTogglingException extends Exception {}

View File

@ -89,6 +89,12 @@ public function register()
], 400); ], 400);
}); });
$this->renderable(function (FailedIconStoreDatabaseTogglingException $exception, $request) {
return response()->json([
'message' => __('errors.failed_icon_store_database_toggling'),
], 400);
});
$this->renderable(function (\Illuminate\Auth\AuthenticationException $exception, $request) { $this->renderable(function (\Illuminate\Auth\AuthenticationException $exception, $request) {
if ($exception->guards() === ['reverse-proxy-guard']) { if ($exception->guards() === ['reverse-proxy-guard']) {
return response()->json([ return response()->json([

17
app/Facades/IconStore.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace App\Facades;
use App\Services\IconStoreService;
use Illuminate\Support\Facades\Facade;
/**
* @see \App\Services\IconStoreService
*/
class IconStore extends Facade
{
protected static function getFacadeAccessor()
{
return IconStoreService::class;
}
}

17
app/Facades/Icons.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace App\Facades;
use App\Services\IconService;
use Illuminate\Support\Facades\Facade;
/**
* @see \App\Services\IconService
*/
class Icons extends Facade
{
protected static function getFacadeAccessor()
{
return IconService::class;
}
}

View File

@ -2,6 +2,8 @@
namespace App\Helpers; namespace App\Helpers;
use Illuminate\Support\Str;
class Helpers class Helpers
{ {
/** /**
@ -39,4 +41,12 @@ public static function commaSeparatedToArray($ids) : mixed
return $ids; return $ids;
} }
/**
* Generate a unique filename with the given extension
*/
public static function getRandomFilename(string $extension, int $length = 40) : string
{
return Str::random($length) . '.' . $extension;
}
} }

View File

@ -3,8 +3,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Events\TwoFAccountDeleted; use App\Events\TwoFAccountDeleted;
use App\Facades\IconStore;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CleanIconStorage class CleanIconStorage
{ {
@ -25,7 +25,8 @@ public function __construct()
*/ */
public function handle(TwoFAccountDeleted $event) public function handle(TwoFAccountDeleted $event)
{ {
Storage::disk('icons')->delete($event->twofaccount->icon ?? []); IconStore::delete($event->twofaccount->icon ?? []);
Log::info(sprintf('Icon cleaned for deleted TwoFAccount #%d', $event->twofaccount->id)); Log::info(sprintf('Icon cleaned for deleted TwoFAccount #%d', $event->twofaccount->id));
} }
} }

View File

@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Events\storeIconsInDatabaseSettingChanged;
use App\Facades\IconStore;
class ToggleIconReplicationToDatabase
{
/**
* Create the event listener.
*/
public function __construct()
{
}
/**
* Handle the event.
*/
public function handle(storeIconsInDatabaseSettingChanged $event): void
{
IconStore::setDatabaseReplication($event->newValue);
}
}

View File

@ -43,8 +43,22 @@
* @property string|null $method * @property string|null $method
* @property string|null $login_method * @property string|null $login_method
* @property-read Model|\Eloquent $authenticatable * @property-read Model|\Eloquent $authenticatable
*
* @mixin \Eloquent * @mixin \Eloquent
* @method static \Database\Factories\AuthLogFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog query()
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereAuthenticatableId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereAuthenticatableType($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereClearedByUser($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereGuard($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereIpAddress($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereLoginAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereLoginMethod($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereLoginSuccessful($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereLogoutAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|AuthLog whereUserAgent($value)
*/ */
class AuthLog extends Model class AuthLog extends Model
{ {

View File

@ -20,7 +20,6 @@
* @property int|null $user_id * @property int|null $user_id
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\TwoFAccount[] $twofaccounts * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\TwoFAccount[] $twofaccounts
* @property-read \App\Models\User|null $user * @property-read \App\Models\User|null $user
*
* @method static \Database\Factories\GroupFactory factory(...$parameters) * @method static \Database\Factories\GroupFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|Group newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Group newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Group newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Group newQuery()
@ -30,8 +29,8 @@
* @method static \Illuminate\Database\Eloquent\Builder|Group whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|Group whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Group whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Group whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Group whereUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|Group whereUserId($value)
*
* @mixin \Eloquent * @mixin \Eloquent
* @method static \Illuminate\Database\Eloquent\Builder|Group orphans()
*/ */
class Group extends Model class Group extends Model
{ {

111
app/Models/Icon.php Normal file
View File

@ -0,0 +1,111 @@
<?php
namespace App\Models;
use App\Models\Traits\CanEncryptField;
use Database\Factories\IconFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\App;
/**
* App\Models\Icon
*
* @property string $name
* @property string|null $content
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\TwoFAccount|null $twofaccount
* @method static \Database\Factories\IconFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|Icon newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Icon newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Icon query()
* @method static \Illuminate\Database\Eloquent\Builder|Icon whereContent($value)
* @method static \Illuminate\Database\Eloquent\Builder|Icon whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Icon whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Icon whereUpdatedAt($value)
*/
class Icon extends Model
{
/**
* @use HasFactory<IconFactory>
*/
use HasFactory, CanEncryptField;
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'name';
/**
* The "type" of the primary key ID.
*
* @var string
*/
protected $keyType = 'string';
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
/**
* Get the twofaccount that owns the icon.
*
* @return BelongsTo<\App\Models\TwoFAccount, \App\Models\Icon>
*/
public function twofaccount() : BelongsTo
{
return $this->belongsTo(TwoFAccount::class, 'name', 'icon');
}
/**
* The model's attributes.
*
* @var array
*/
protected $attributes = [];
/**
* The attributes that should be hidden for arrays.
*
* @var array<int, string>
*/
protected $hidden = ['created_at', 'updated_at'];
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = ['name'];
/**
* Get content attribute
*
* @param string $value
* @return string
*/
public function getContentAttribute($value)
{
return $this->decryptOrReturn(base64_decode($value));
}
/**
* Set content attribute
*
* @param string $value
* @return void
*/
public function setContentAttribute($value)
{
// Encrypt if needed
$this->attributes['content'] = $this->encryptOrReturn(base64_encode($value));
}
}

View File

@ -10,14 +10,12 @@
* @property int $id * @property int $id
* @property string $key * @property string $key
* @property string $value * @property string $value
*
* @method static \Illuminate\Database\Eloquent\Builder|Option newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Option newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Option newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Option newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Option query() * @method static \Illuminate\Database\Eloquent\Builder|Option query()
* @method static \Illuminate\Database\Eloquent\Builder|Option whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|Option whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Option whereKey($value) * @method static \Illuminate\Database\Eloquent\Builder|Option whereKey($value)
* @method static \Illuminate\Database\Eloquent\Builder|Option whereValue($value) * @method static \Illuminate\Database\Eloquent\Builder|Option whereValue($value)
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class Option extends Model class Option extends Model

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models\Traits;
use App\Facades\Settings;
use Illuminate\Support\Facades\Crypt;
trait CanEncryptField
{
/**
* Returns an acceptable value
*/
private function decryptOrReturn(mixed $value) : mixed
{
// Decipher when needed
if (Settings::get('useEncryption') && $value) {
try {
return Crypt::decryptString($value);
} catch (\Exception $ex) {
return __('errors.indecipherable');
}
} else {
return $value;
}
}
/**
* Encrypt a value
*/
private function encryptOrReturn(mixed $value) : mixed
{
// should be replaced by laravel 8 attribute encryption casting
return Settings::get('useEncryption') ? Crypt::encryptString($value) : $value;
}
}

View File

@ -7,19 +7,17 @@
use App\Exceptions\InvalidSecretException; use App\Exceptions\InvalidSecretException;
use App\Exceptions\UndecipherableException; use App\Exceptions\UndecipherableException;
use App\Exceptions\UnsupportedOtpTypeException; use App\Exceptions\UnsupportedOtpTypeException;
use App\Facades\Settings; use App\Facades\Icons;
use App\Helpers\Helpers; use App\Helpers\Helpers;
use App\Models\Dto\HotpDto; use App\Models\Dto\HotpDto;
use App\Models\Dto\TotpDto; use App\Models\Dto\TotpDto;
use App\Services\IconService; use App\Models\Traits\CanEncryptField;
use Database\Factories\TwoFAccountFactory; use Database\Factories\TwoFAccountFactory;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use OTPHP\Factory; use OTPHP\Factory;
@ -72,17 +70,16 @@
* @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereService($value) * @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereService($value)
* @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount whereUserId($value)
*
* @mixin \Eloquent * @mixin \Eloquent
* @property-read \App\Models\Icon|null $iconResource
* @method static \Illuminate\Database\Eloquent\Builder|TwoFAccount orphans()
*/ */
class TwoFAccount extends Model implements Sortable class TwoFAccount extends Model implements Sortable
{ {
/** /**
* @use HasFactory<TwoFAccountFactory> * @use HasFactory<TwoFAccountFactory>
*/ */
use HasFactory; use HasFactory, SortableTrait, CanEncryptField;
use SortableTrait;
const TOTP = 'totp'; const TOTP = 'totp';
@ -234,6 +231,16 @@ public function user()
return $this->belongsTo(\App\Models\User::class); return $this->belongsTo(\App\Models\User::class);
} }
/**
* Get the relation between the icon resource and the model.
*
* @return HasOne<\App\Models\Icon>
*/
public function iconResource(): HasOne
{
return $this->hasOne(Icon::class, 'name', 'icon');
}
/** /**
* Scope a query to only include orphan (userless) accounts. * Scope a query to only include orphan (userless) accounts.
* *
@ -467,7 +474,7 @@ public function fillWithOtpParameters(array $parameters, bool $skipIconFetching
} }
if (! $this->icon && ! $skipIconFetching && Auth::user()?->preferences['getOfficialIcons']) { if (! $this->icon && ! $skipIconFetching && Auth::user()?->preferences['getOfficialIcons']) {
$this->icon = App::make(IconService::class)->buildFromOfficialLogo($this->service); $this->icon = Icons::buildFromOfficialLogo($this->service);
} }
Log::info(sprintf('TwoFAccount filled with OTP parameters')); Log::info(sprintf('TwoFAccount filled with OTP parameters'));
@ -534,11 +541,12 @@ public function fillWithURI(string $uri, bool $isSteamTotp = false, bool $skipIc
$this->enforceAsSteam(); $this->enforceAsSteam();
} }
if ($this->generator->hasParameter('image')) { if ($this->generator->hasParameter('image')) {
$this->icon = App::make(IconService::class)->buildFromRemoteImage($this->generator->getParameter('image')); $this->icon = Icons::buildFromRemoteImage($this->generator->getParameter('image'));
} }
$uuu = Auth::user()?->preferences;
if (! $this->icon && ! $skipIconFetching && Auth::user()?->preferences['getOfficialIcons']) { if (! $this->icon && ! $skipIconFetching && Auth::user()?->preferences['getOfficialIcons']) {
$this->icon = App::make(IconService::class)->buildFromOfficialLogo($this->service); $this->icon = Icons::buildFromOfficialLogo($this->service);
} }
Log::info(sprintf('TwoFAccount filled with an URI')); Log::info(sprintf('TwoFAccount filled with an URI'));
@ -646,34 +654,6 @@ private function initGenerator() : void
} }
} }
/**
* Returns an acceptable value
*/
private function decryptOrReturn(mixed $value) : mixed
{
// Decipher when needed
if (Settings::get('useEncryption') && $value) {
try {
return Crypt::decryptString($value);
} catch (Exception $ex) {
Log::debug(sprintf('Service field of twofaccount with id #%s cannot be deciphered', $this->id));
return __('errors.indecipherable');
}
} else {
return $value;
}
}
/**
* Encrypt a value
*/
private function encryptOrReturn(mixed $value) : mixed
{
// should be replaced by laravel 8 attribute encryption casting
return Settings::get('useEncryption') ? Crypt::encryptString($value) : $value;
}
/** /**
* @return \Illuminate\Database\Eloquent\Builder<TwoFAccount> * @return \Illuminate\Database\Eloquent\Builder<TwoFAccount>
*/ */

View File

@ -47,7 +47,6 @@
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\AuthLog> $authentications * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\AuthLog> $authentications
* @property-read int|null $authentications_count * @property-read int|null $authentications_count
* @property-read \App\Models\AuthLog|null $latestAuthentication * @property-read \App\Models\AuthLog|null $latestAuthentication
*
* @method static \Illuminate\Database\Eloquent\Builder|User admins() * @method static \Illuminate\Database\Eloquent\Builder|User admins()
* @method static \Database\Factories\UserFactory factory(...$parameters) * @method static \Database\Factories\UserFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery()
@ -64,8 +63,9 @@
* @method static \Illuminate\Database\Eloquent\Builder|User wherePreferences($value) * @method static \Illuminate\Database\Eloquent\Builder|User wherePreferences($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereRememberToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereUpdatedAt($value)
*
* @mixin \Eloquent * @mixin \Eloquent
* @method static \Illuminate\Database\Eloquent\Builder|User whereOauthId($value)
* @method static \Illuminate\Database\Eloquent\Builder|User whereOauthProvider($value)
*/ */
class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthenticatable class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthenticatable
{ {

View File

@ -2,12 +2,13 @@
namespace App\Observers; namespace App\Observers;
use App\Facades\IconStore;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Storage;
class UserObserver class UserObserver
{ {
@ -66,7 +67,8 @@ public function deleting(User $user) : bool
})->map(function ($twofaccount, $key) { })->map(function ($twofaccount, $key) {
return $twofaccount->icon; return $twofaccount->icon;
}); });
Storage::disk('icons')->delete($iconPathes->toArray());
IconStore::delete($iconPathes->toArray());
return true; return true;
} }

View File

@ -4,6 +4,7 @@
use App\Events\GroupDeleted; use App\Events\GroupDeleted;
use App\Events\GroupDeleting; use App\Events\GroupDeleting;
use App\Events\storeIconsInDatabaseSettingChanged;
use App\Events\ScanForNewReleaseCalled; use App\Events\ScanForNewReleaseCalled;
use App\Events\TwoFAccountDeleted; use App\Events\TwoFAccountDeleted;
use App\Events\VisitedByProxyUser; use App\Events\VisitedByProxyUser;
@ -14,6 +15,7 @@
use App\Listeners\CleanIconStorage; use App\Listeners\CleanIconStorage;
use App\Listeners\DissociateTwofaccountFromGroup; use App\Listeners\DissociateTwofaccountFromGroup;
use App\Listeners\LogNotificationListener; use App\Listeners\LogNotificationListener;
use App\Listeners\ToggleIconReplicationToDatabase;
use App\Listeners\RegisterOpenId; use App\Listeners\RegisterOpenId;
use App\Listeners\ReleaseRadar; use App\Listeners\ReleaseRadar;
use App\Listeners\ResetUsersPreference; use App\Listeners\ResetUsersPreference;
@ -69,6 +71,9 @@ class EventServiceProvider extends ServiceProvider
VisitedByProxyUser::class => [ VisitedByProxyUser::class => [
VisitedByProxyUserListener::class, VisitedByProxyUserListener::class,
], ],
storeIconsInDatabaseSettingChanged::class => [
ToggleIconReplicationToDatabase::class,
],
]; ];
/** /**
@ -76,6 +81,7 @@ class EventServiceProvider extends ServiceProvider
* *
* @var array<string, string|object|array<int, string|object>> * @var array<string, string|object|array<int, string|object>>
*/ */
// TODO: bind the observer using the ObservedBy attribute (https://laravel.com/docs/11.x/eloquent#defining-observers)
protected $observers = [ protected $observers = [
User::class => [UserObserver::class], User::class => [UserObserver::class],
]; ];

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Factories\MigratorFactoryInterface; use App\Factories\MigratorFactoryInterface;
use App\Services\IconStoreService;
use App\Services\IconService; use App\Services\IconService;
use App\Services\LogoService; use App\Services\LogoService;
use App\Services\ReleaseRadarService; use App\Services\ReleaseRadarService;
@ -29,11 +30,15 @@ public function register()
return new SettingService; return new SettingService;
}); });
$this->app->singleton(LogoService::class, function () { $this->app->singleton(IconStoreService::class, function () {
return new IconStoreService;
});
$this->app->singleton(LogoService::class, function ($app) {
return new LogoService; return new LogoService;
}); });
$this->app->singleton(IconService::class, function () { $this->app->singleton(IconService::class, function ($app) {
return new IconService; return new IconService;
}); });
@ -67,7 +72,9 @@ public function provides()
{ {
return [ return [
IconService::class, IconService::class,
IconStoreService::class,
LogoService::class, LogoService::class,
QrReader::class,
ReleaseRadarService::class, ReleaseRadarService::class,
]; ];
} }

View File

@ -2,7 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Services\LogoService; use App\Facades\IconStore;
use App\Helpers\Helpers;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -10,9 +11,6 @@
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/**
* App\Services\IconService
*/
class IconService class IconService
{ {
/** /**
@ -31,16 +29,19 @@ public function buildFromOfficialLogo(?string $service) : ?string
*/ */
public function buildFromResource($resource, $extension) : ?string public function buildFromResource($resource, $extension) : ?string
{ {
// TODO : controller la valeur de $extension if (! $resource || ! $extension) {
$filename = self::getRandomName($extension); return null;
}
if (Storage::disk('icons')->put($filename, $resource)) { $filename = Helpers::getRandomFilename($extension);
if (self::isValidImageFile($filename, 'icons')) {
if (IconStore::store($filename, $resource)) {
if (self::isValidImageResource($filename, $resource)) {
Log::info(sprintf('Image "%s" successfully stored for import', $filename)); Log::info(sprintf('Image "%s" successfully stored for import', $filename));
return $filename; return $filename;
} else { } else {
Storage::disk('icons')->delete($filename); IconStore::delete($filename);
} }
} }
@ -52,10 +53,10 @@ public function buildFromResource($resource, $extension) : ?string
*/ */
public function buildFromRemoteImage(string $url) : ?string public function buildFromRemoteImage(string $url) : ?string
{ {
$isRemoteData = Str::startsWith($url, ['http://', 'https://']) && Validator::make( $isRemoteData = Validator::make(
[$url], [$url],
['url'] ['url']
)->passes(); )->passes() && Str::startsWith($url, ['http://', 'https://']);
return $isRemoteData ? $this->storeRemoteImage($url) : null; return $isRemoteData ? $this->storeRemoteImage($url) : null;
} }
@ -67,7 +68,7 @@ protected function storeRemoteImage(string $url) : ?string
{ {
try { try {
$path_parts = pathinfo($url); $path_parts = pathinfo($url);
$filename = $this->getRandomName($path_parts['extension']); $filename = Helpers::getRandomFilename($path_parts['extension']);
try { try {
$response = Http::withOptions([ $response = Http::withOptions([
@ -83,9 +84,10 @@ protected function storeRemoteImage(string $url) : ?string
return null; return null;
} }
if (self::isValidImageFile($filename, 'imagesLink')) { $imagesLinkResource = Storage::disk('imagesLink')->get($filename);
if ($imagesLinkResource && self::isValidImageResource($filename, $imagesLinkResource)) {
// Should be a valid image, we move it to the icons disk // Should be a valid image, we move it to the icons disk
if (Storage::disk('icons')->put($filename, Storage::disk('imagesLink')->get($filename))) { if (IconStore::store($filename, $imagesLinkResource)) {
Storage::disk('imagesLink')->delete($filename); Storage::disk('imagesLink')->delete($filename);
} }
@ -95,7 +97,7 @@ protected function storeRemoteImage(string $url) : ?string
throw new \Exception('Unsupported mimeType or missing image on storage'); throw new \Exception('Unsupported mimeType or missing image on storage');
} }
if (Storage::disk('icons')->exists($filename)) { if (IconStore::exists($filename)) {
return $filename; return $filename;
} }
} }
@ -108,30 +110,32 @@ protected function storeRemoteImage(string $url) : ?string
return null; return null;
} }
/**
* Generate a unique filename
*
*/
private static function getRandomName(string $extension) : string
{
return Str::random(40) . '.' . $extension;
}
/** /**
* Validate a file is a valid image * Validate a file is a valid image
* *
* @param string $filename * @param string $filename
* @param string $disk * @param string $content
*/ */
public static function isValidImageFile($filename, $disk) : bool public static function isValidImageResource($filename, $content) : bool
{ {
return in_array(Storage::disk($disk)->mimeType($filename), [ Storage::disk('temp')->put($filename, $content);
$extension = Str::replace('jpg', 'jpeg', pathinfo($filename, PATHINFO_EXTENSION), false);
$mimeType = Storage::disk('temp')->mimeType($filename);
$acceptedMimeTypes = [
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/webp', 'image/webp',
'image/bmp', 'image/bmp',
'image/x-ms-bmp', 'image/x-ms-bmp',
'image/svg+xml', 'image/svg+xml',
]) && (Storage::disk($disk)->mimeType($filename) !== 'image/svg+xml' ? getimagesize(Storage::disk($disk)->path($filename)) : true); ];
$isValid = in_array($mimeType, $acceptedMimeTypes)
&& ($mimeType !== 'image/svg+xml' ? getimagesize(Storage::disk('temp')->path($filename)) : true)
&& Str::contains($mimeType, $extension, true);
Storage::disk('temp')->delete($filename);
return $isValid;
} }
} }

View File

@ -0,0 +1,276 @@
<?php
namespace App\Services;
use App\Exceptions\FailedIconStoreDatabaseTogglingException;
use App\Facades\Settings;
use App\Models\Icon;
use App\Models\TwoFAccount;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Mockery\MockInterface;
class IconStoreService
{
/**
* The storage disk to use
*/
protected string|null $disk;
/**
* Icon replication to database to ease backup
*/
protected bool $usesDatabase;
/**
*
*/
public function __construct()
{
$this->usesDatabase = Settings::get('storeIconsInDatabase');
$this->setDisk();
}
/**
* The storage disk instance
*/
protected function disk() : Filesystem|MockInterface
{
return Storage::disk($this->disk);
}
/**
* Set the storage disk to use
*
* @return $this
*/
public function setDisk(string $diskName = 'icons')
{
$this->disk = $diskName;
return $this;
}
/**
* Whether or not database replication is enabled on the store.
* This should always equals the 'storeIconsInDatabase' setting
*/
public function usesDatabase() : bool
{
return $this->usesDatabase;
}
/**
* Toggle database replication
*/
public function setDatabaseReplication(bool $usesDatabase) : void
{
if ($this->usesDatabase != $usesDatabase) {
if ($usesDatabase) {
$this->clearDatabase();
$this->mirrorDiskToDatabase();
}
else {
$this->mirrorDatabaseToDisk();
$this->clearDatabase();
}
$this->usesDatabase = $usesDatabase;
}
}
/**
* Insert all registered icons into the database
*/
protected function mirrorDiskToDatabase() : void
{
DB::beginTransaction();
try {
foreach ($this->registeredIcons() as $filename) {
if ($content = $this->get($filename)) {
$this->storeToDatabase($filename, $content);
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollback();
throw new FailedIconStoreDatabaseTogglingException;
}
}
/**
* Save all database records as file in the disk
*/
protected function mirrorDatabaseToDisk() : void
{
foreach (Icon::all() as $icon) {
if (! $this->storeToDisk($icon->name, $icon->content)) {
throw new FailedIconStoreDatabaseTogglingException;
}
}
}
/**
* Get the list of all icon names registered in the TwoFAccount table
*
* @return Collection<array-key, mixed>
*/
protected function registeredIcons()
{
return TwoFAccount::whereNotNull('icon')->pluck('icon');
}
/**
* Get the content of a given icon resource, prior to the database record
*/
public function get(string $name) : ?string
{
return $this->usesDatabase
? Icon::find($name)?->content
: $this->disk()->get($name);
}
/**
* Get the mime-type of a given icon resource
*/
public function mimeType(string $name) : string|false
{
if ($this->usesDatabase && $this->missingInDisk($name)) {
$this->storeToDisk($name, $this->get($name));
}
return $this->disk()->mimeType($name);
}
/**
* Delete all icons from the storage
*/
public function clear() : bool
{
$diskCleared = $this->clearDisk();
if ($diskCleared && $this->usesDatabase) {
$this->clearDatabase();
}
return $diskCleared;
}
/**
* Delete all icons on the disk
*/
protected function clearDisk() : bool
{
$filesForDelete = Arr::where($this->disk()->files(), function (string $filename) {
return Str::endsWith($filename, ['png', 'jpg', 'jpeg', 'bmp', 'webp', 'svg']);
});
return $this->disk()->delete($filesForDelete);
}
/**
* Delete all icons from the database
*/
protected function clearDatabase() : void
{
Icon::truncate();
}
/**
* Delete the given icons from the storage
*/
public function delete(array|string $names) : bool
{
$names = is_array($names) ? $names : func_get_args();
$deletedFromDisk = $this->disk()->delete($names);
if ($deletedFromDisk && $this->usesDatabase) {
Icon::destroy($names);
return Icon::whereIn('name', $names)->count() == 0;
}
return $deletedFromDisk;
}
/**
* Create the given icon in the storage
*/
public function store(string $name, string $content) : bool
{
$storedToDisk = $this->storeToDisk($name, $content);
if ($this->usesDatabase) {
return $this->storeToDatabase($name, $content);
}
return $storedToDisk;
}
/**
* Create the given icon in the disk
*/
protected function storeToDisk(string $name, string $content) : bool
{
return $this->disk()->put($name, $content);
}
/**
* Create the given icon in the database
*/
protected function storeToDatabase(string $name, string $content) : bool
{
$icon = Icon::firstOrNew(['name' => $name]);
$icon->content = $content;
return $icon->save();
}
/**
* Determines if an icon exists in the store, prior to the database.
* If a database record does not have the corresponding file in disk, it will create it.
*/
public function exists(string $name) : bool
{
if ($this->usesDatabase) {
$exists = $this->existsInDatabase($name);
if ($exists && $this->missingInDisk($name)) {
$this->storeToDisk($name, $this->get($name));
}
return $exists;
} else {
return $this->existsInDisk($name);
}
}
/**
* Determine if an icon exists in the database
*/
protected function existsInDatabase(string $name) : bool
{
return Icon::find($name) != null;
}
/**
* Determine if an icon exists in the database
*/
protected function existsInDisk(string $name) : bool
{
return $this->disk()->exists($name);
}
/**
* Determine if an icon is missing in the database
*/
protected function missingInDisk(string $name) : bool
{
return ! $this->existsInDisk($name);
}
}

View File

@ -2,14 +2,14 @@
namespace App\Services; namespace App\Services;
use App\Facades\IconStore;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class LogoService class LogoService
{ {
/** /**
* @var \Illuminate\Support\Collection<string, string> * @var \Illuminate\Support\Collection<string, string>
*/ */
@ -25,6 +25,14 @@ class LogoService
*/ */
const TFA_URL = 'https://2fa.directory/api/v3/tfa.json'; const TFA_URL = 'https://2fa.directory/api/v3/tfa.json';
/**
* @var string
*/
const TFA_IMG_URL = 'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/';
/**
*
*/
public function __construct() public function __construct()
{ {
$this->setTfaCollection(); $this->setTfaCollection();
@ -41,9 +49,10 @@ public function getIcon(?string $serviceName)
$logoFilename = $this->getLogo(strval($serviceName)); $logoFilename = $this->getLogo(strval($serviceName));
if ($logoFilename) { if ($logoFilename) {
$iconFilename = Str::random(40) . '.svg'; // $iconFilename = IconService::getRandomName('svg');
$iconFilename = \Illuminate\Support\Str::random(40) . '.svg';
return $this->copyToIcons($logoFilename, $iconFilename) ? $iconFilename : null; return $this->copyToIconStore($logoFilename, $iconFilename) ? $iconFilename : null;
} else { } else {
return null; return null;
} }
@ -121,7 +130,7 @@ protected function fetchLogo(string $logoFile) : void
try { try {
$response = Http::withOptions([ $response = Http::withOptions([
'proxy' => config('2fauth.config.outgoingProxy'), 'proxy' => config('2fauth.config.outgoingProxy'),
])->retry(3, 100)->get('https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/' . $logoFile[0] . '/' . $logoFile); ])->retry(3, 100)->get(self::TFA_IMG_URL . $logoFile[0] . '/' . $logoFile);
if ($response->successful()) { if ($response->successful()) {
Storage::disk('logos')->put($logoFile, $response->body()) Storage::disk('logos')->put($logoFile, $response->body())
@ -144,14 +153,18 @@ protected function cleanDomain(string $domain) : string
} }
/** /**
* Copy a logo file to the icons disk with a new name * Copy a logo file to the icons store with a new name
* *
* @param string $logoFilename * @param string $logoFilename
* @param string $iconFilename * @param string $iconFilename
* @return bool Weither the copy succed or not * @return bool Whether the copy succeed or not
*/ */
protected function copyToIcons($logoFilename, $iconFilename) : bool protected function copyToIconStore($logoFilename, $iconFilename) : bool
{ {
return Storage::disk('icons')->put($iconFilename, Storage::disk('logos')->get($logoFilename)); if ($content = Storage::disk('logos')->get($logoFilename)) {
return IconStore::store($iconFilename, $content);
}
return false;
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Events\storeIconsInDatabaseSettingChanged;
use App\Exceptions\DbEncryptionException; use App\Exceptions\DbEncryptionException;
use App\Models\Option; use App\Models\Option;
use Exception; use Exception;
@ -67,26 +68,23 @@ public function all() : Collection
/** /**
* Set a setting * Set a setting
* *
* @param string|array $setting A single setting name or an associative array of name:value settings * @param string $setting A single setting name
* @param string|int|bool|null $value The value for single setting * @param string|int|bool $value The value for single setting
*/ */
public function set($setting, $value = null) : void public function set($setting, $value) : void
{ {
$settings = is_array($setting) ? $setting : [$setting => $value]; // TODO: Move setEncryptionTo() logic to a dedicated class
if ($setting === 'useEncryption') {
foreach ($settings as $setting => $value) { $this->setEncryptionTo($value);
if ($setting === 'useEncryption') {
$this->setEncryptionTo($value);
}
$settings[$setting] = $this->replaceBoolean($value);
} }
foreach ($settings as $setting => $value) { if ($setting === 'storeIconsInDatabase') {
Option::updateOrCreate(['key' => $setting], ['value' => $value]); storeIconsInDatabaseSettingChanged::dispatch($value);
Log::notice(sprintf('App setting %s set to %s', var_export($setting, true), var_export($this->restoreType($value), true)));
} }
Option::updateOrCreate(['key' => $setting], ['value' => $this->replaceBoolean($value)]);
Log::notice(sprintf('App setting %s set to %s', var_export($setting, true), var_export($this->restoreType($value), true)));
self::buildAndCache(); self::buildAndCache();
} }
@ -207,6 +205,7 @@ private function updateRecords(bool $encrypted) : bool
{ {
$success = true; $success = true;
$twofaccounts = DB::table('twofaccounts')->get(); $twofaccounts = DB::table('twofaccounts')->get();
$icons = DB::table('icons')->get();
$twofaccounts->each(function ($item, $key) use (&$success, $encrypted) { $twofaccounts->each(function ($item, $key) use (&$success, $encrypted) {
try { try {
@ -227,6 +226,17 @@ private function updateRecords(bool $encrypted) : bool
} }
}); });
$icons->each(function ($item, $key) use (&$success, $encrypted) {
try {
$item->content = $encrypted ? Crypt::encryptString($item->content) : Crypt::decryptString($item->content);
} catch (Exception $ex) {
$success = false;
// Exit the each iteration
return false;
}
});
if ($success) { if ($success) {
// The whole collection has now its sensible data encrypted/decrypted // The whole collection has now its sensible data encrypted/decrypted
// We update the db using a transaction that can rollback everything if an error occured // We update the db using a transaction that can rollback everything if an error occured
@ -244,6 +254,14 @@ private function updateRecords(bool $encrypted) : bool
]); ]);
}); });
$icons->each(function ($item, $key) {
DB::table('icons')
->where('name', $item->name)
->update([
'content' => $item->content,
]);
});
DB::commit(); DB::commit();
return true; return true;

View File

@ -94,6 +94,7 @@
'restrictList' => '', 'restrictList' => '',
'restrictRule' => '', 'restrictRule' => '',
'keepSsoRegistrationEnabled' => false, 'keepSsoRegistrationEnabled' => false,
'storeIconsInDatabase' => false,
], ],
/* /*

View File

@ -13,6 +13,7 @@
| |
*/ */
// TODO : disable this env var and make driver be set from admin panel
'default' => env('FILESYSTEM_DISK', env('FILESYSTEM_DRIVER', 'local')), 'default' => env('FILESYSTEM_DISK', env('FILESYSTEM_DRIVER', 'local')),
/* /*
@ -44,6 +45,18 @@
'throw' => false, 'throw' => false,
], ],
// 'tempicons' => [
// 'driver' => 'local',
// 'root' => storage_path('app/tempicons'),
// 'throw' => false,
// ],
'temp' => [
'driver' => 'local',
'root' => storage_path('app/temp'),
'throw' => false,
],
'logos' => [ 'logos' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/logos'), 'root' => storage_path('app/logos'),

View File

@ -0,0 +1,86 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Tests\Data\OtpTestData;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Icon>
*/
class IconFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => OtpTestData::ICON_PNG,
'content' => base64_decode(OtpTestData::ICON_PNG_DATA),
];
}
/**
* Indicate that the icon is a jpeg image.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Icon>
*/
public function jpeg()
{
return $this->state(function (array $attributes) {
return [
'name' => OtpTestData::ICON_JPEG,
'content' => base64_decode(OtpTestData::ICON_JPEG_DATA),
];
});
}
/**
* Indicate that the icon is a webp image.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Icon>
*/
public function webp()
{
return $this->state(function (array $attributes) {
return [
'name' => OtpTestData::ICON_WEBP,
'content' => base64_decode(OtpTestData::ICON_WEBP_DATA),
];
});
}
/**
* Indicate that the icon is a bmp image.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Icon>
*/
public function bmp()
{
return $this->state(function (array $attributes) {
return [
'name' => OtpTestData::ICON_BMP,
'content' => base64_decode(OtpTestData::ICON_BMP_DATA),
];
});
}
/**
* Indicate that the icon is a svg image.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Icon>
*/
public function svg()
{
return $this->state(function (array $attributes) {
return [
'name' => OtpTestData::ICON_SVG,
'content' => base64_decode(OtpTestData::ICON_SVG_DATA_ENCODED),
];
});
}
}

View File

@ -1,12 +1,12 @@
<?php <?php
use App\Services\SettingService;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Facades\Settings;
class ChangeNullableInTwofaccountsTable extends Migration class ChangeNullableInTwofaccountsTable extends Migration
{ {
@ -17,18 +17,19 @@ class ChangeNullableInTwofaccountsTable extends Migration
*/ */
public function up() public function up()
{ {
$settingService = new SettingService;
$twofaccounts = DB::table('twofaccounts')->select('id', 'legacy_uri')->get(); $twofaccounts = DB::table('twofaccounts')->select('id', 'legacy_uri')->get();
foreach ($twofaccounts as $twofaccount) { foreach ($twofaccounts as $twofaccount) {
try { try {
$legacy_uri = Settings::get('useEncryption') ? Crypt::decryptString($twofaccount->legacy_uri) : $twofaccount->legacy_uri; $legacy_uri = $settingService->get('useEncryption') ? Crypt::decryptString($twofaccount->legacy_uri) : $twofaccount->legacy_uri;
$token = \OTPHP\Factory::loadFromProvisioningUri($legacy_uri); $token = \OTPHP\Factory::loadFromProvisioningUri($legacy_uri);
$affected = DB::table('twofaccounts') $affected = DB::table('twofaccounts')
->where('id', $twofaccount->id) ->where('id', $twofaccount->id)
->update([ ->update([
'otp_type' => get_class($token) === 'OTPHP\TOTP' ? 'totp' : 'hotp', 'otp_type' => get_class($token) === 'OTPHP\TOTP' ? 'totp' : 'hotp',
'secret' => Settings::get('useEncryption') ? Crypt::encryptString($token->getSecret()) : $token->getSecret(), 'secret' => $settingService->get('useEncryption') ? Crypt::encryptString($token->getSecret()) : $token->getSecret(),
'algorithm' => $token->getDigest(), 'algorithm' => $token->getDigest(),
'digits' => $token->getDigits(), 'digits' => $token->getDigits(),
'period' => $token->hasParameter('period') ? $token->getParameter('period') : null, 'period' => $token->hasParameter('period') ? $token->getParameter('period') : null,

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Facades\Settings;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Services\SettingService;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@ -33,7 +33,8 @@ public function down(): void
*/ */
protected function dbIsEncrypted() : bool protected function dbIsEncrypted() : bool
{ {
return Settings::get('useEncryption'); $settingService = new SettingService;
return $settingService->get('useEncryption');
} }
/** /**

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('icons', function (Blueprint $table) {
$table->string('name')->primary();
$table->longText('content');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('icons');
}
};

View File

@ -3,8 +3,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Models\User;
use App\Models\Group;
use App\Models\TwoFAccount;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class TestingSeeder extends Seeder class TestingSeeder extends Seeder

View File

@ -120,7 +120,7 @@ library.add(
faTabletScreenButton, faTabletScreenButton,
faDisplay, faDisplay,
faCalendar, faCalendar,
faArrowUpLong faArrowUpLong,
); );
export default FontAwesomeIcon export default FontAwesomeIcon

View File

@ -97,9 +97,12 @@
<label class="label" v-html="$t('admin.forms.health_endpoint.label')" /> <label class="label" v-html="$t('admin.forms.health_endpoint.label')" />
<p class="help" v-html="$t('admin.forms.health_endpoint.help')" /> <p class="help" v-html="$t('admin.forms.health_endpoint.help')" />
</div> </div>
<div> <div class="field mb-5">
<a target="_blank" :href="healthEndPoint">{{ healthEndPointFullPath }}</a> <a target="_blank" :href="healthEndPoint">{{ healthEndPointFullPath }}</a>
</div> </div>
<h4 class="title is-4 pt-5 has-text-grey-light">{{ $t('admin.storage') }}</h4>
<!-- store icons in database -->
<FormCheckbox v-model="appSettings.storeIconsInDatabase" @update:model-value="val => useAppSettingsUpdater('storeIconsInDatabase', val)" fieldName="storeIconsInDatabase" label="admin.forms.store_icon_to_database.label" help="admin.forms.store_icon_to_database.help" />
<h4 class="title is-4 pt-5 has-text-grey-light">{{ $t('settings.security') }}</h4> <h4 class="title is-4 pt-5 has-text-grey-light">{{ $t('settings.security') }}</h4>
<!-- protect db --> <!-- protect db -->
<FormCheckbox v-model="appSettings.useEncryption" @update:model-value="val => useAppSettingsUpdater('useEncryption', val)" fieldName="useEncryption" label="admin.forms.use_encryption.label" help="admin.forms.use_encryption.help" /> <FormCheckbox v-model="appSettings.useEncryption" @update:model-value="val => useAppSettingsUpdater('useEncryption', val)" fieldName="useEncryption" label="admin.forms.use_encryption.label" help="admin.forms.use_encryption.help" />

View File

@ -399,7 +399,8 @@
</transition> </transition>
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.exact="showOrCopy(account)" @keyup.enter="showOrCopy(account)" @click.ctrl="getAndCopyOTP(account)" role="button"> <div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.exact="showOrCopy(account)" @keyup.enter="showOrCopy(account)" @click.ctrl="getAndCopyOTP(account)" role="button">
<div class="tfa-text has-ellipsis"> <div class="tfa-text has-ellipsis">
<img role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/icons/' + account.icon" v-if="account.icon && user.preferences.showAccountsIcons" alt=""> <img v-if="account.icon && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/icons/' + account.icon" alt="">
<img v-else-if="account.icon == null && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/noicon.svg'" alt="">
{{ account.service ? account.service : $t('twofaccounts.no_service') }}<FontAwesomeIcon class="has-text-danger is-size-5 ml-2" v-if="appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" /> {{ account.service ? account.service : $t('twofaccounts.no_service') }}<FontAwesomeIcon class="has-text-danger is-size-5 ml-2" v-if="appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
<span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span> <span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
</div> </div>

View File

@ -85,6 +85,9 @@
'sort_by_date_asc' => 'Show least recent first', 'sort_by_date_asc' => 'Show least recent first',
'sort_by_date_desc' => 'Show most recent first', 'sort_by_date_desc' => 'Show most recent first',
'single_sign_on' => 'Single Sign-On (SSO)', 'single_sign_on' => 'Single Sign-On (SSO)',
'database' => 'Database',
'file_system' => 'File system',
'storage' => 'Storage',
'forms' => [ 'forms' => [
'use_encryption' => [ 'use_encryption' => [
'label' => 'Protect sensitive data', 'label' => 'Protect sensitive data',
@ -134,7 +137,11 @@
'cache_management' => [ 'cache_management' => [
'label' => 'Cache management', 'label' => 'Cache management',
'help' => 'Sometimes cache needs to be cleared, for instance after a change to environment variables or an update. You can do it from here.', 'help' => 'Sometimes cache needs to be cleared, for instance after a change to environment variables or an update. You can do it from here.',
] ],
'store_icon_to_database' => [
'label' => 'Store icons to database',
'help' => 'Uploaded icons are registered in the database in addition to the file system storage, which is then used only as a cache. This makes creating a 2FAuth backup much easier, as only the database has to be backed up.<br /><br />But beware, this may has some drawbacks: The database size may increase significantly if the instance hosts many large icons. It may also affect the application performance because the file system is hit more often to ensure it is synchronised with the database.',
],
], ],
]; ];

View File

@ -73,4 +73,5 @@
'cannot_decode_detected_qrcode' => 'Cannot decode detected QR code, try to crop or sharpen the image', 'cannot_decode_detected_qrcode' => 'Cannot decode detected QR code, try to crop or sharpen the image',
'qrcode_has_invalid_checksum' => 'QR code has invalid checksum', 'qrcode_has_invalid_checksum' => 'QR code has invalid checksum',
'no_readable_qrcode' => 'No readable QR code', 'no_readable_qrcode' => 'No readable QR code',
'failed_icon_store_database_toggling' => 'Migration of icons failed. The setting has been restored to its previous value.',
]; ];

View File

@ -3,11 +3,18 @@
namespace Tests\Api\v1\Controllers; namespace Tests\Api\v1\Controllers;
use App\Api\v1\Controllers\IconController; use App\Api\v1\Controllers\IconController;
use App\Facades\IconStore;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Models\User; use App\Models\User;
use App\Services\LogoService;
use Illuminate\Http\Testing\FileFactory;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Data\HttpRequestTestData;
use Tests\Data\OtpTestData;
use Tests\FeatureTestCase; use Tests\FeatureTestCase;
/** /**
@ -25,13 +32,26 @@ public function setUp() : void
{ {
parent::setUp(); parent::setUp();
Storage::fake('icons');
Storage::fake('logos');
Http::preventStrayRequests();
Http::fake([
LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]);
Http::fake([
OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
]);
$this->user = User::factory()->create(); $this->user = User::factory()->create();
} }
#[Test] #[Test]
public function test_upload_icon_returns_filename() public function test_upload_icon_returns_filename_using_the_iconStore()
{ {
$file = UploadedFile::fake()->image('testIcon.jpg'); $iconName = 'testIcon.jpg';
$file = UploadedFile::fake()->image($iconName);
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/icons', [ ->json('POST', '/api/v1/icons', [
@ -43,6 +63,17 @@ public function test_upload_icon_returns_filename()
]); ]);
} }
#[Test]
public function test_upload_icon_stores_it_to_database()
{
$file = UploadedFile::fake()->image('testIcon.jpg');
$response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/icons', [
'icon' => $file,
]);
}
#[Test] #[Test]
public function test_upload_with_invalid_data_returns_validation_error() public function test_upload_with_invalid_data_returns_validation_error()
{ {
@ -58,7 +89,7 @@ public function test_fetch_logo_returns_filename()
{ {
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/icons/default', [ ->json('POST', '/api/v1/icons/default', [
'service' => 'dropbox', 'service' => 'service',
]) ])
->assertStatus(201) ->assertStatus(201)
->assertJsonStructure([ ->assertJsonStructure([
@ -77,11 +108,17 @@ public function test_fetch_unknown_logo_returns_nothing()
} }
#[Test] #[Test]
public function test_delete_icon_returns_success() public function test_delete_icon_returns_success_using_the_iconStore()
{ {
IconStore::spy();
$iconName = 'testIcon.jpg';
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('DELETE', '/api/v1/icons/testIcon.jpg') ->json('DELETE', '/api/v1/icons/' . $iconName)
->assertNoContent(204); ->assertNoContent(204);
IconStore::shouldHaveReceived('delete')->once()->with($iconName);
} }
#[Test] #[Test]

View File

@ -15,12 +15,16 @@
use App\Policies\TwoFAccountPolicy; use App\Policies\TwoFAccountPolicy;
use App\Providers\MigrationServiceProvider; use App\Providers\MigrationServiceProvider;
use App\Providers\TwoFAuthServiceProvider; use App\Providers\TwoFAuthServiceProvider;
use App\Services\LogoService;
use Illuminate\Http\Testing\FileFactory;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Classes\LocalFile; use Tests\Classes\LocalFile;
use Tests\Data\HttpRequestTestData;
use Tests\Data\MigrationTestData; use Tests\Data\MigrationTestData;
use Tests\Data\OtpTestData; use Tests\Data\OtpTestData;
use Tests\FeatureTestCase; use Tests\FeatureTestCase;
@ -219,6 +223,19 @@ public function setUp() : void
{ {
parent::setUp(); parent::setUp();
Storage::fake('icons');
Storage::fake('logos');
Storage::fake('imagesLink');
Http::preventStrayRequests();
Http::fake([
LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]);
Http::fake([
OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
]);
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->userGroupA = Group::factory()->for($this->user)->create(); $this->userGroupA = Group::factory()->for($this->user)->create();
$this->userGroupB = Group::factory()->for($this->user)->create(); $this->userGroupB = Group::factory()->for($this->user)->create();
@ -435,7 +452,6 @@ public function test_show_twofaccount_of_another_user_is_forbidden()
public function test_store_without_encryption_returns_success_with_consistent_resource_structure($payload, $expected) public function test_store_without_encryption_returns_success_with_consistent_resource_structure($payload, $expected)
{ {
Settings::set('useEncryption', false); Settings::set('useEncryption', false);
Storage::put('test.png', 'emptied to prevent missing resource replaced by null by the model getter');
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/twofaccounts', $payload) ->json('POST', '/api/v1/twofaccounts', $payload)
@ -449,7 +465,6 @@ public function test_store_without_encryption_returns_success_with_consistent_re
public function test_store_with_encryption_returns_success_with_consistent_resource_structure($payload, $expected) public function test_store_with_encryption_returns_success_with_consistent_resource_structure($payload, $expected)
{ {
Settings::set('useEncryption', true); Settings::set('useEncryption', true);
Storage::put('test.png', 'emptied to prevent missing resource replaced by null by the model getter');
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/twofaccounts', $payload) ->json('POST', '/api/v1/twofaccounts', $payload)
@ -1161,8 +1176,24 @@ public function test_preview_with_invalid_data_returns_validation_error()
} }
#[Test] #[Test]
public function test_preview_with_unreachable_image_returns_success() public function test_preview_with_unreachable_image_but_official_logo_returns_success()
{ {
$this->user['preferences->getOfficialIcons'] = true;
$response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/twofaccounts/preview', [
'uri' => OtpTestData::TOTP_URI_WITH_UNREACHABLE_IMAGE,
])
->assertOk();
$this->assertNotNull($response->json('icon'));
}
#[Test]
public function test_preview_with_unreachable_image_returns_success_with_no_icon()
{
$this->user['preferences->getOfficialIcons'] = false;
$response = $this->actingAs($this->user, 'api-guard') $response = $this->actingAs($this->user, 'api-guard')
->json('POST', '/api/v1/twofaccounts/preview', [ ->json('POST', '/api/v1/twofaccounts/preview', [
'uri' => OtpTestData::TOTP_URI_WITH_UNREACHABLE_IMAGE, 'uri' => OtpTestData::TOTP_URI_WITH_UNREACHABLE_IMAGE,
@ -1224,6 +1255,25 @@ public function test_export_twofaccount_of_another_user_is_forbidden()
]); ]);
} }
#[Test]
public function test_export_returns_nulled_icon_resource_when_icon_file_is_missing()
{
$this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(array_merge(
self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP,
[
'icon' => 'icon_without_file_on_disk.png',
]
));
$response = $this->actingAs($this->user, 'api-guard')
->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id)
->assertJsonFragment([
'icon' => 'icon_without_file_on_disk.png',
'icon_file' => null,
'icon_mime' => null,
]);
}
#[Test] #[Test]
public function test_get_otp_using_totp_twofaccount_id_returns_consistent_resource() public function test_get_otp_using_totp_twofaccount_id_returns_consistent_resource()
{ {

View File

@ -13,10 +13,10 @@ class HttpRequestTestData
const TFA_JSON_BODY = ' const TFA_JSON_BODY = '
[ [
[ [
"Twitch", "Service",
{ {
"domain": "twitch.tv", "domain": "service.com",
"url": "https://www.twitch.tv/", "url": "https://www.service.com/",
"tfa": "tfa":
[ [
"sms", "sms",
@ -27,45 +27,32 @@ class HttpRequestTestData
[ [
"Authy" "Authy"
], ],
"documentation": "https://help.twitch.tv/s/article/two-factor-authentication", "documentation": "https://service.com/doc",
"notes": "To activate two factor authentication, you must provide a mobile phone number.", "notes": "To activate two factor authentication, you must provide a mobile phone number.",
"keywords": "keywords":
[ [
"entertainment" "entertainment"
] ]
} },
], "FakeService",
[
"Twitter",
{ {
"domain": "twitter.com", "domain": "fakeservice.com",
"url": "https://www.fakeservice.com/",
"tfa": "tfa":
[ [
"sms", "sms",
"totp", "custom-software",
"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" "totp"
], ],
"documentation": "https://support.txbit.io/support/solutions/articles/44000447137", "custom-software":
[
"Authy"
],
"documentation": "https://fakeservice.com/doc",
"notes": "To activate two factor authentication, you must provide a mobile phone number.",
"keywords": "keywords":
[ [
"cryptocurrencies" "entertainment"
] ]
} }
] ]

View File

@ -32,7 +32,9 @@ class OtpTestData
const COUNTER_CUSTOM = 5; const COUNTER_CUSTOM = 5;
const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png'; const EXTERNAL_IMAGE_URL_DECODED = 'https://en.opensuse.org/images/4/44/Button-filled-colour.png';
const EXTERNAL_IMAGE_URL_ENCODED = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
const ICON_PNG = 'test.png'; const ICON_PNG = 'test.png';
@ -56,6 +58,10 @@ class OtpTestData
const ICON_SVG_DATA_ENCODED = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo='; const ICON_SVG_DATA_ENCODED = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo=';
const ICON_GIF = 'test.gif';
const ICON_GIF_DATA = 'R0lGODlhAQACAPcAAAAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAACH5BAEAAPwALAAAAAABAAIAAAgFAPftCwgAOw==';
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_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 MICROSOFT = 'Microsoft'; const MICROSOFT = 'Microsoft';
@ -64,17 +70,21 @@ class OtpTestData
const TOTP_MICROSOFT_CORPORATE_URI_MISMATCHING_ISSUER = 'otpauth://totp/' . self::ORGANIZATION . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::MICROSOFT; const TOTP_MICROSOFT_CORPORATE_URI_MISMATCHING_ISSUER = 'otpauth://totp/' . self::ORGANIZATION . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::MICROSOFT;
const TOTP_FULL_CUSTOM_URI = self::TOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::IMAGE; const TOTP_FULL_CUSTOM_URI = self::TOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::EXTERNAL_IMAGE_URL_ENCODED;
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_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 HOTP_FULL_CUSTOM_URI = self::HOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::EXTERNAL_IMAGE_URL_ENCODED;
const TOTP_SHORT_URI = 'otpauth://totp/' . self::ACCOUNT . '?secret=' . self::SECRET; const TOTP_SHORT_URI = 'otpauth://totp/' . self::ACCOUNT . '?secret=' . self::SECRET;
const HOTP_SHORT_URI = 'otpauth://hotp/' . 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 UNREACHABLE_IMAGE_URL = 'https%3A%2F%2Fen.opensuse.org%2Fimage.png';
const UNREACHABLE_IMAGE_URL_DECODED = 'https://en.opensuse.or/image.png';
const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=' . self::UNREACHABLE_IMAGE_URL;
const INVALID_OTPAUTH_URI = 'otpauth://Xotp/' . self::ACCOUNT . '?secret=' . self::SECRET; const INVALID_OTPAUTH_URI = 'otpauth://Xotp/' . self::ACCOUNT . '?secret=' . self::SECRET;

View File

@ -3,7 +3,6 @@
namespace Tests\Feature\Console; namespace Tests\Feature\Console;
use App\Console\Commands\CheckDbConnection; use App\Console\Commands\CheckDbConnection;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\FeatureTestCase; use Tests\FeatureTestCase;

View File

@ -2,6 +2,7 @@
namespace Tests\Feature\Models; namespace Tests\Feature\Models;
use App\Facades\Icons;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Models\User; use App\Models\User;
use App\Services\LogoService; use App\Services\LogoService;
@ -10,7 +11,6 @@
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface; use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Data\HttpRequestTestData; use Tests\Data\HttpRequestTestData;
use Tests\Data\OtpTestData; use Tests\Data\OtpTestData;
@ -95,9 +95,10 @@ public function test_fill_with_custom_totp_uri_returns_correct_value()
{ {
$file = (new FileFactory)->image('file.png', 10, 10); $file = (new FileFactory)->image('file.png', 10, 10);
// TODO: set preventStrayRequests in parent class to ensure all tests use a fake
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response($file->tempFile, 200), OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response($file->tempFile, 200),
]); ]);
Storage::fake('imagesLink'); Storage::fake('imagesLink');
@ -163,7 +164,7 @@ public function test_fill_with_custom_hotp_uri_returns_correct_value()
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response($file->tempFile, 200), OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response($file->tempFile, 200),
]); ]);
Storage::fake('imagesLink'); Storage::fake('imagesLink');
@ -251,18 +252,14 @@ public function test_fill_with_uri_without_label_returns_ValidationException()
} }
#[Test] #[Test]
public function test_fill_with_getOfficialIcons_On_triggers_icon_fetching() public function test_fill_with_getOfficialIcons_On_fetches_icon_using_Icons_facade()
{ {
// Set the getOfficialIcons preference On
$this->user['preferences->getOfficialIcons'] = true; $this->user['preferences->getOfficialIcons'] = true;
$this->user->save(); $this->user->save();
$this->mock(LogoService::class, function (MockInterface $logoService) { Icons::shouldReceive('buildFromOfficialLogo')
$logoService->expects() ->twice()
->getIcon(OtpTestData::SERVICE) ->andReturn('file.png');
->twice()
->andReturn(null);
});
$twofaccount = new TwoFAccount; $twofaccount = new TwoFAccount;
$twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG); $twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG);
@ -469,7 +466,7 @@ public function test_getOTP_for_totp_returns_the_same_password()
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response(HttpRequestTestData::ICON_PNG, 200), OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response(HttpRequestTestData::ICON_PNG, 200),
]); ]);
Storage::fake('imagesLink'); Storage::fake('imagesLink');
@ -497,7 +494,7 @@ public function test_getOTP_for_hotp_returns_the_same_password()
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://en.opensuse.org/images/4/44/Button-filled-colour.png' => Http::response(HttpRequestTestData::ICON_PNG, 200), OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response(HttpRequestTestData::ICON_PNG, 200),
]); ]);
Storage::fake('imagesLink'); Storage::fake('imagesLink');
@ -589,6 +586,9 @@ public function test_getURI_for_custom_hotp_model_returns_uri()
public function test_fill_succeed_when_image_fetching_fails() public function test_fill_succeed_when_image_fetching_fails()
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([
OtpTestData::EXTERNAL_IMAGE_URL_DECODED => new \Exception,
]);
Storage::fake('imagesLink'); Storage::fake('imagesLink');
Storage::fake('icons'); Storage::fake('icons');
@ -600,6 +600,17 @@ public function test_fill_succeed_when_image_fetching_fails()
Storage::disk('imagesLink')->assertDirectoryEmpty('/'); Storage::disk('imagesLink')->assertDirectoryEmpty('/');
} }
#[Test]
public function test_fillWithURI_uses_Icons_facade_to_get_the_icon()
{
Icons::shouldReceive('buildFromRemoteImage')
->once()
->andReturn('file.png');
$twofaccount = new TwoFAccount;
$twofaccount->fillWithURI(OtpTestData::TOTP_FULL_CUSTOM_URI);
}
#[Test] #[Test]
public function test_saving_totp_without_period_set_default_one() public function test_saving_totp_without_period_set_default_one()
{ {

View File

@ -2,7 +2,9 @@
namespace Tests\Feature\Models; namespace Tests\Feature\Models;
use App\Facades\IconStore;
use App\Models\AuthLog; use App\Models\AuthLog;
use App\Models\Dto\IconDto;
use App\Models\Group; use App\Models\Group;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Models\User; use App\Models\User;
@ -168,9 +170,11 @@ public function test_delete_flushes_icons_of_user_twofaccounts()
$user = User::factory()->create(); $user = User::factory()->create();
$twofaccount = TwoFAccount::factory()->for($user)->create(); $twofaccount = TwoFAccount::factory()->for($user)->create([
$twofaccount->setIcon(base64_decode(OtpTestData::ICON_PNG_DATA), 'png'); 'icon' => OtpTestData::ICON_PNG,
$twofaccount->save(); ]);
IconStore::store(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->assertExists($twofaccount->icon); Storage::disk('icons')->assertExists($twofaccount->icon);
$user->delete(); $user->delete();

View File

@ -5,98 +5,239 @@
use App\Services\IconService; use App\Services\IconService;
use App\Services\LogoService; use App\Services\LogoService;
use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Http\Testing\FileFactory;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\DataProviderExternal;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Data\HttpRequestTestData; use Tests\Data\HttpRequestTestData;
use Tests\TestCase; use Tests\Data\OtpTestData;
use Tests\FeatureTestCase;
/** /**
* IconServiceTest test class * IconServiceTest test class
*/ */
#[CoversClass(IconService::class)] #[CoversClass(IconService::class)]
class IconServiceTest extends TestCase class IconServiceTest extends FeatureTestCase
{ {
use WithoutMiddleware; use WithoutMiddleware;
/**
*
*/
protected IconService $iconService;
public function setUp() : void public function setUp() : void
{ {
parent::setUp(); parent::setUp();
Storage::fake('icons');
Storage::fake('logos');
Storage::fake('imagesLink');
Http::preventStrayRequests();
Http::fake([
LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]);
Http::fake([
OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
]);
} }
// #[Test] #[Test]
// #[DataProvider('iconResourceProvider')] public function test_buildFromOfficialLogo_calls_logoservice_to_get_the_icon()
// public function test_set_icon_stores_and_set_the_icon($res, $ext) {
// { $logoServiceSpy = $this->spy(LogoService::class);
// Storage::fake('imagesLink');
// Storage::fake('icons');
// $previousIcon = $this->customTotpTwofaccount->icon; $this->iconService = $this->app->make(IconService::class);
// $this->customTotpTwofaccount->setIcon($res, $ext); $this->iconService->buildFromOfficialLogo('fakeService');
// $this->assertNotEquals($previousIcon, $this->customTotpTwofaccount->icon); $logoServiceSpy->shouldHaveReceived('getIcon')->once()->with('fakeService');
}
// Storage::disk('icons')->assertExists($this->customTotpTwofaccount->icon); #[Test]
// Storage::disk('imagesLink')->assertMissing($this->customTotpTwofaccount->icon); public function test_buildFromResource_stores_icon_and_returns_name()
// } {
$resource = base64_decode(OtpTestData::ICON_PNG_DATA);
$extension = 'png';
// /** $this->iconService = $this->app->make(IconService::class);
// * Provide data for Icon store tests $iconName = $this->iconService->buildFromResource($resource, $extension);
// */
// public static function iconResourceProvider()
// {
// return [
// 'PNG' => [
// base64_decode(OtpTestData::ICON_PNG_DATA),
// 'png',
// ],
// 'JPG' => [
// base64_decode(OtpTestData::ICON_JPEG_DATA),
// 'jpg',
// ],
// 'WEBP' => [
// base64_decode(OtpTestData::ICON_WEBP_DATA),
// 'webp',
// ],
// 'BMP' => [
// base64_decode(OtpTestData::ICON_BMP_DATA),
// 'bmp',
// ],
// 'SVG' => [
// OtpTestData::ICON_SVG_DATA,
// 'svg',
// ],
// ];
// }
// #[Test] Storage::disk('icons')->assertExists($iconName);
// #[DataProvider('invalidIconResourceProvider')] $this->assertEquals($resource, Storage::disk('icons')->get($iconName));
// public function test_set_invalid_icon_ends_without_error($res, $ext) }
// {
// Storage::fake('imagesLink');
// Storage::fake('icons');
// $previousIcon = $this->customTotpTwofaccount->icon; #[Test]
// $this->customTotpTwofaccount->setIcon($res, $ext); public function test_buildFromResource_returns_null_when_store_fails()
{
Storage::shouldReceive('disk->put')
->andReturn(false);
// $this->assertEquals($previousIcon, $this->customTotpTwofaccount->icon); $this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromResource('lorem', 'ipsum');
// Storage::disk('icons')->assertMissing($this->customTotpTwofaccount->icon); $this->assertNull($iconName);
// Storage::disk('imagesLink')->assertMissing($this->customTotpTwofaccount->icon); }
// }
// /** #[Test]
// * Provide data for Icon store tests #[DataProvider('badBuildFromResourceInputsProvider')]
// */ public function test_buildFromResource_with_bad_inputs_returns_null($resource, $extension)
// public static function invalidIconResourceProvider() {
// { $this->iconService = $this->app->make(IconService::class);
// return [ $iconName = $this->iconService->buildFromResource($resource, $extension);
// 'INVALID_PNG' => [
// 'lkjdslfkjslkdfjlskdjflksjf', $this->assertNull($iconName);
// 'png', }
// ],
// ]; /**
// } * Provide bad inputs for buildFromResource test
} */
public static function badBuildFromResourceInputsProvider()
{
return [
'NULL_RESOURCE' => [
null,
'png',
],
'EMPTY_RESOURCE' => [
'',
'png',
],
'BAD_RESOURCE_TYPE' => [
false,
'png',
],
'NULL_EXTENSION' => [
base64_decode(OtpTestData::ICON_PNG_DATA),
null,
],
'EMPTY_EXTENSION' => [
base64_decode(OtpTestData::ICON_PNG_DATA),
'',
],
'BAD_EXTENSION_TYPE' => [
base64_decode(OtpTestData::ICON_PNG_DATA),
false,
],
'UNSUPPORTED_EXTENSION' => [
base64_decode(OtpTestData::ICON_GIF_DATA),
'gif',
],
'UNCONSISTENT_EXTENSION' => [
base64_decode(OtpTestData::ICON_PNG_DATA),
'jpeg',
],
];
}
#[Test]
#[DataProviderExternal(IconStoreServiceTest::class, 'supportedMimeTypesProvider')]
public function test_buildFromRemoteImage_stores_icon_and_returns_name($name, $base64content, $mimetype)
{
$imageUrl = 'https://' . $name;
$resource = base64_decode($base64content);
Http::preventStrayRequests();
Http::fake([
$imageUrl => Http::response($resource, 200),
]);
$this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromRemoteImage($imageUrl);
Storage::disk('icons')->assertExists($iconName);
Storage::disk('imagesLink')->assertMissing($iconName);
$this->assertEquals($resource, Storage::disk('icons')->get($iconName));
}
#[Test]
#[DataProvider('buildFromRemoteImageInvalidUrlProvider')]
public function test_buildFromRemoteImage_returns_null_when_url_is_invalid()
{
$imageUrl = 'not_a_valid_url';
$validator = Mockery::mock('stdClass');
Validator::swap($validator);
Validator::shouldReceive('make')
->once()
->with([$imageUrl], ['url'])
->andReturn($validator);
Validator::shouldReceive('passes')
->once()
->andReturn(false);
$this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromRemoteImage($imageUrl);
$this->assertNull($iconName);
}
/**
* Provide invalid urls for buildFromRemoteImage test
*/
public static function buildFromRemoteImageInvalidUrlProvider()
{
return [
'FTP' => [
'ftp://example.com/file.txt',
],
'NO_SCHEME' => [
'example.com/file.txt',
],
'BRACKET' => [
'http://example.com/file[/].html',
],
];
}
#[Test]
public function test_buildFromRemoteImage_returns_null_when_remote_img_is_unreachable()
{
$imageUrl = 'https://icon.png';
$this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromRemoteImage($imageUrl);
$this->assertNull($iconName);
}
#[Test]
public function test_buildFromRemoteImage_returns_null_when_remote_img_is_not_supported()
{
$imageUrl = 'https://icon.gif';
Http::preventStrayRequests();
Http::fake([
$imageUrl => Http::response('fakeBody', 200),
]);
$this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromRemoteImage($imageUrl);
$this->assertNull($iconName);
}
#[Test]
public function test_buildFromRemoteImage_returns_null_when_remote_img_is_not_valid_resource()
{
$imageUrl = 'https://icon.png';
$resource = 'invalid_img_resource';
Http::preventStrayRequests();
Http::fake([
$imageUrl => Http::response($resource, 200),
]);
$this->iconService = $this->app->make(IconService::class);
$iconName = $this->iconService->buildFromRemoteImage($imageUrl);
$this->assertNull($iconName);
}
}

View File

@ -0,0 +1,743 @@
<?php
namespace Tests\Feature\Services;
use App\Exceptions\FailedIconStoreDatabaseTogglingException;
use App\Facades\Settings;
use App\Models\Icon;
use App\Models\TwoFAccount;
use App\Models\User;
use App\Services\IconStoreService;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use Tests\Data\OtpTestData;
use Tests\FeatureTestCase;
/**
* IconStoreTest test class
*/
#[CoversClass(IconStoreService::class)]
class IconStoreServiceTest extends FeatureTestCase
{
use WithoutMiddleware;
/**
* The IconStore under test
*/
protected IconStoreService $iconStore;
public function setUp() : void
{
parent::setUp();
Storage::fake('icons');
$this->iconStore = $this->app->make(IconStoreService::class);
}
#[Test]
public function test_get_returns_icon_content()
{
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
$content = $this->iconStore->get(OtpTestData::ICON_PNG);
$this->assertSame(OtpTestData::ICON_PNG_DATA, base64_encode($content));
}
#[Test]
public function test_get_returns_null_when_icon_is_missing()
{
$this->assertNull($this->iconStore->get(OtpTestData::ICON_PNG));
}
#[Test]
public function test_get_returns_content_when_icon_is_missing_on_disk_but_exists_in_db()
{
Settings::set('storeIconsInDatabase', true);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$content = $this->iconStore->get(OtpTestData::ICON_PNG);
$this->assertSame(OtpTestData::ICON_PNG_DATA, base64_encode($content));
}
#[Test]
public function test_get_returns_null_when_nothing_is_requested()
{
$this->assertNull($this->iconStore->get(''));
}
#[Test]
#[DataProvider('supportedMimeTypesProvider')]
public function test_mimeType_returns_correct_mimetype($name, $base64content, $expected)
{
Storage::disk('icons')->put($name, $base64content);
$mimeType = $this->iconStore->mimeType($name);
$this->assertStringContainsStringIgnoringCase($mimeType, $expected);
}
/**
* Provide data for index tests
*/
public static function supportedMimeTypesProvider()
{
return [
'PNG' => [
OtpTestData::ICON_PNG,
OtpTestData::ICON_PNG_DATA,
'image/png',
],
'JPEG' => [
OtpTestData::ICON_JPEG,
OtpTestData::ICON_JPEG_DATA,
'image/jpeg',
],
'WEBP' => [
OtpTestData::ICON_WEBP,
OtpTestData::ICON_WEBP_DATA,
'image/webp',
],
'BPM' => [
OtpTestData::ICON_BMP,
OtpTestData::ICON_BMP_DATA,
'image/bmp|image/x-ms-bmp'
],
'SVG' => [
OtpTestData::ICON_SVG,
OtpTestData::ICON_SVG_DATA_ENCODED,
'image/svg+xml',
],
];
}
#[Test]
public function test_mimeType_returns_correct_mimetype_of_fake_icon()
{
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_PNG_DATA));
$mimeType = $this->iconStore->mimeType(OtpTestData::ICON_JPEG);
$this->assertSame('image/png', $mimeType);
}
#[Test]
public function test_mimeType_returns_null_when_icon_is_missing()
{
$this->assertFalse($this->iconStore->mimeType(OtpTestData::ICON_PNG));
}
#[Test]
public function test_mimeType_returns_null_when_nothing_is_requested()
{
$this->assertFalse($this->iconStore->mimeType(''));
}
#[Test]
public function test_clear_deletes_all_icons_and_returns_true()
{
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$result = $this->iconStore->clear();
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_JPEG);
$this->assertEmpty(Storage::disk('icons')->allFiles());
$this->assertTrue($result);
}
#[Test]
public function test_clear_deletes_all_icons_in_database_and_returns_true()
{
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
$result = $this->iconStore->clear();
$this->assertEmpty(Storage::disk('icons')->allFiles());
$this->assertDatabaseEmpty('icons');
$this->assertTrue($result);
}
#[Test]
public function test_clear_empty_disk_returns_true()
{
$this->assertTrue($this->iconStore->clear());
$this->assertEmpty(Storage::disk('icons')->allFiles());
}
#[Test]
public function test_clear_empty_disk_deletes_icons_in_database()
{
Settings::set('storeIconsInDatabase', true);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
$result = $this->iconStore->clear();
$this->assertDatabaseEmpty('icons');
$this->assertTrue($result);
}
#[Test]
public function test_clear_deletes_only_supported_image_format_in_disk()
{
Storage::disk('icons')->put(OtpTestData::ICON_GIF, base64_decode(OtpTestData::ICON_GIF_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_BMP, base64_decode(OtpTestData::ICON_BMP_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_SVG, OtpTestData::ICON_SVG_DATA);
Storage::disk('icons')->put(OtpTestData::ICON_WEBP, base64_decode(OtpTestData::ICON_WEBP_DATA));
Storage::disk('icons')->assertExists(OtpTestData::ICON_GIF);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_BMP);
Storage::disk('icons')->assertExists(OtpTestData::ICON_SVG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_WEBP);
$this->iconStore->clear();
Storage::disk('icons')->assertExists(OtpTestData::ICON_GIF);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_JPEG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_BMP);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_SVG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_WEBP);
}
#[Test]
public function test_delete_deletes_provided_icon_and_returns_true()
{
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
$this->iconStore->delete(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
}
#[Test]
public function test_delete_deletes_provided_icon_in_database_and_returns_true()
{
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->iconStore->delete(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_PNG,
]);
}
#[Test]
public function test_delete_deletes_provided_icon_in_database_when_disk_is_empty()
{
Settings::set('storeIconsInDatabase', true);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->iconStore->delete(OtpTestData::ICON_PNG);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_PNG,
]);
}
#[Test]
public function test_delete_deletes_provided_icons_and_returns_true()
{
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->iconStore->delete([
OtpTestData::ICON_PNG,
OtpTestData::ICON_JPEG,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_JPEG);
}
#[Test]
public function test_delete_deletes_provided_icons_in_database_and_returns_true()
{
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
$this->iconStore->delete([
OtpTestData::ICON_PNG,
OtpTestData::ICON_JPEG,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_JPEG);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
}
#[Test]
public function test_delete_deletes_provided_icons_in_database_when_disk_is_empty()
{
Settings::set('storeIconsInDatabase', true);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
$this->iconStore->delete([
OtpTestData::ICON_PNG,
OtpTestData::ICON_JPEG,
]);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_JPEG,
]);
}
#[Test]
public function test_delete_missing_icon_returns_true()
{
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$result = $this->iconStore->delete(OtpTestData::ICON_PNG);
$this->assertTrue($result);
}
#[Test]
public function test_delete_empty_icons_returns_true()
{
$result = $this->iconStore->delete([]);
$this->assertTrue($result);
}
#[Test]
public function test_delete_returns_false_when_it_fails()
{
Storage::fake('icons');
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::shouldReceive('disk->delete')
->andReturn(false);
$result = $this->iconStore->delete(OtpTestData::ICON_PNG);
$this->assertFalse($result);
}
#[Test]
public function test_store_writes_the_icon_to_disk_and_returns_true()
{
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$result = $this->iconStore->store(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
$this->assertTrue($result);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
}
#[Test]
public function test_store_writes_the_icon_to_disk_and_database_and_returns_true()
{
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_PNG,
]);
$result = $this->iconStore->store(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
$this->assertTrue($result);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
]);
}
#[Test]
public function test_store_returns_false_when_it_fails()
{
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$iconName = OtpTestData::ICON_PNG;
$iconContent = '';
Storage::shouldReceive('disk->put')
->with($iconName, $iconContent)
->andReturn(false);
$result = $this->iconStore->store($iconName, $iconContent);
$this->assertFalse($result);
}
#[Test]
public function test_exists_returns_true()
{
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
$this->assertTrue($this->iconStore->exists(OtpTestData::ICON_PNG));
}
#[Test]
public function test_exists_fixes_missing_file_in_disk()
{
Settings::set('storeIconsInDatabase', true);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
$this->iconStore->exists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
}
#[Test]
public function test_exists_returns_false()
{
$this->assertFalse($this->iconStore->exists(OtpTestData::ICON_PNG));
}
#[Test]
public function test_setDatabaseReplication_stores_icon_files_to_database()
{
$user = User::factory()->create();
$admin = User::factory()->administrator()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
TwoFAccount::factory()->for($admin)->create([
'icon' => OtpTestData::ICON_JPEG,
]);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
$this->assertDatabaseCount('icons', 2);
}
#[Test]
public function test_setDatabaseReplication_stores_only_registered_icon_to_database()
{
$user = User::factory()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_JPEG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Settings::set('storeIconsInDatabase', true);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
$this->assertDatabaseMissing('icons', [
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
$this->assertDatabaseCount('icons', 1);
}
#[Test]
public function test_setDatabaseReplication_clears_database_before_replication()
{
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Settings::set('storeIconsInDatabase', true);
$this->assertDatabaseEmpty('icons');
}
#[Test]
public function test_setDatabaseReplication_skips_icon_when_file_is_missing()
{
$user = User::factory()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
Storage::disk('icons')->assertMissing(OtpTestData::ICON_PNG);
Settings::set('storeIconsInDatabase', true);
$this->assertDatabaseEmpty('icons');
}
#[Test]
public function test_setDatabaseReplication_restores_icons_as_file_and_clears_database()
{
Settings::set('storeIconsInDatabase', true);
$user = User::factory()->create();
$admin = User::factory()->administrator()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
TwoFAccount::factory()->for($admin)->create([
'icon' => OtpTestData::ICON_JPEG,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
Settings::set('storeIconsInDatabase', false);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseEmpty('icons');
}
#[Test]
public function test_setDatabaseReplication_overrides_existing_files_during_restoration_from_database()
{
Settings::set('storeIconsInDatabase', true);
$user = User::factory()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_JPEG_DATA));
Settings::set('storeIconsInDatabase', false);
$this->assertEquals(base64_decode(OtpTestData::ICON_PNG_DATA), Storage::disk('icons')->get(OtpTestData::ICON_PNG));
}
#[Test]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function test_setDatabaseReplication_On_sends_exception_and_does_nothing_if_filling_database_fails()
{
$user = User::factory()->create();
$admin = User::factory()->administrator()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
TwoFAccount::factory()->for($admin)->create([
'icon' => OtpTestData::ICON_JPEG,
]);
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_PNG_DATA));
Storage::disk('icons')->put(OtpTestData::ICON_PNG, base64_decode(OtpTestData::ICON_JPEG_DATA));
$this->expectException(FailedIconStoreDatabaseTogglingException::class);
$mock = $this->mock('overload:' . Icon::class, function (MockInterface $iconModel) {
$iconModel->shouldReceive('truncate')
->andReturn(true);
$iconModel->shouldReceive('firstOrNew')
->andThrow(new \Exception);
});
Settings::set('storeIconsInDatabase', true);
$this->assertDatabaseEmpty('icons');
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
}
#[Test]
public function test_setDatabaseReplication_Off_sends_exception_and_does_nothing_if_filling_database_fails()
{
Settings::set('storeIconsInDatabase', true);
$user = User::factory()->create();
$admin = User::factory()->administrator()->create();
TwoFAccount::factory()->for($user)->create([
'icon' => OtpTestData::ICON_PNG,
]);
TwoFAccount::factory()->for($admin)->create([
'icon' => OtpTestData::ICON_JPEG,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
DB::table('icons')->insert([
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
Storage::shouldReceive('disk->put')
->once()
->andThrow(new FailedIconStoreDatabaseTogglingException);
$this->expectException(FailedIconStoreDatabaseTogglingException::class);
Settings::set('storeIconsInDatabase', false);
Storage::disk('icons')->assertExists(OtpTestData::ICON_PNG);
Storage::disk('icons')->assertExists(OtpTestData::ICON_JPEG);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_PNG,
'content' => OtpTestData::ICON_PNG_DATA,
]);
$this->assertDatabaseHas('icons', [
'name' => OtpTestData::ICON_JPEG,
'content' => OtpTestData::ICON_JPEG_DATA,
]);
}
}

View File

@ -9,38 +9,38 @@
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Data\HttpRequestTestData; use Tests\Data\HttpRequestTestData;
use Tests\TestCase; use Tests\FeatureTestCase;
/** /**
* LogoServiceTest test class * LogoServiceTest test class
*/ */
#[CoversClass(LogoService::class)] #[CoversClass(LogoService::class)]
class LogoServiceTest extends TestCase class LogoServiceTest extends FeatureTestCase
{ {
use WithoutMiddleware; use WithoutMiddleware;
protected LogoService $logoService;
public function setUp() : void public function setUp() : void
{ {
parent::setUp(); parent::setUp();
Storage::fake('icons');
Storage::fake('logos');
Storage::fake('imagesLink');
} }
#[Test] #[Test]
public function test_getIcon_returns_stored_icon_file_when_logo_exists() public function test_getIcon_returns_stored_icon_file_when_logo_exists()
{ {
$svgLogo = HttpRequestTestData::SVG_LOGO_BODY;
$tfaJsonBody = HttpRequestTestData::TFA_JSON_BODY;
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response($svgLogo, 200), LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
'https://2fa.directory/api/v3/tfa.json' => Http::response($tfaJsonBody, 200), LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]); ]);
Storage::fake('icons'); $this->logoService = $this->app->make(LogoService::class);
Storage::fake('logos'); $icon = $this->logoService->getIcon('service');
$logoService = new LogoService;
$icon = $logoService->getIcon('twitter');
$this->assertNotNull($icon); $this->assertNotNull($icon);
Storage::disk('icons')->assertExists($icon); Storage::disk('icons')->assertExists($icon);
@ -51,14 +51,12 @@ public function test_getIcon_returns_null_when_github_request_fails()
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response('not found', 404), LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response('not found', 404),
]); ]);
Storage::fake('icons'); $this->logoService = $this->app->make(LogoService::class);
Storage::fake('logos'); $icon = $this->logoService->getIcon('service');
$logoService = new LogoService;
$icon = $logoService->getIcon('twitter');
$this->assertEquals(null, $icon); $this->assertEquals(null, $icon);
} }
@ -66,18 +64,14 @@ public function test_getIcon_returns_null_when_github_request_fails()
#[Test] #[Test]
public function test_getIcon_returns_null_when_logo_fetching_fails() public function test_getIcon_returns_null_when_logo_fetching_fails()
{ {
$tfaJsonBody = HttpRequestTestData::TFA_JSON_BODY;
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://2fa.directory/api/v3/tfa.json' => Http::response($tfaJsonBody, 200), LogoService::TFA_IMG_URL . '*' => Http::response('not found', 404),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]); ]);
Storage::fake('icons'); $this->logoService = $this->app->make(LogoService::class);
Storage::fake('logos'); $icon = $this->logoService->getIcon('service');
$logoService = new LogoService;
$icon = $logoService->getIcon('twitter');
$this->assertEquals(null, $icon); $this->assertEquals(null, $icon);
} }
@ -85,9 +79,14 @@ public function test_getIcon_returns_null_when_logo_fetching_fails()
#[Test] #[Test]
public function test_getIcon_returns_null_when_no_logo_exists() public function test_getIcon_returns_null_when_no_logo_exists()
{ {
$logoService = new LogoService; Http::preventStrayRequests();
Http::fake([
LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]);
$icon = $logoService->getIcon('no_logo_should_exists_with_this_name'); $this->logoService = $this->app->make(LogoService::class);
$icon = $this->logoService->getIcon('no_logo_should_exists_with_this_name');
$this->assertEquals(null, $icon); $this->assertEquals(null, $icon);
} }
@ -95,18 +94,14 @@ public function test_getIcon_returns_null_when_no_logo_exists()
#[Test] #[Test]
public function test_logoService_loads_empty_collection_when_tfajson_fetching_fails() public function test_logoService_loads_empty_collection_when_tfajson_fetching_fails()
{ {
$svgLogo = HttpRequestTestData::SVG_LOGO_BODY;
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://raw.githubusercontent.com/2factorauth/twofactorauth/master/img/*' => Http::response($svgLogo, 200), LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response('not found', 404),
]); ]);
Storage::fake('icons'); $this->logoService = $this->app->make(LogoService::class);
Storage::fake('logos'); $icon = $this->logoService->getIcon('service');
$logoService = new LogoService;
$icon = $logoService->getIcon('twitter');
$this->assertNull($icon); $this->assertNull($icon);
Storage::disk('logos')->assertMissing(LogoService::TFA_JSON); Storage::disk('logos')->assertMissing(LogoService::TFA_JSON);

View File

@ -2,15 +2,23 @@
namespace Tests\Feature\Services; namespace Tests\Feature\Services;
use App\Events\storeIconsInDatabaseSettingChanged;
use App\Exceptions\FailedIconStoreDatabaseTogglingException;
use App\Facades\IconStore;
use App\Facades\Settings; use App\Facades\Settings;
use App\Models\Icon;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Services\IconStoreService;
use App\Services\SettingService; use App\Services\SettingService;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\Data\OtpTestData;
use Tests\FeatureTestCase; use Tests\FeatureTestCase;
/** /**
@ -57,11 +65,11 @@ class SettingServiceTest extends FeatureTestCase
private const PERIOD_CUSTOM = 40; private const PERIOD_CUSTOM = 40;
private const IMAGE = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png'; private const EXTERNAL_IMAGE_URL_ENCODED = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
private const ICON = 'test.png'; private const ICON = 'test.png';
private 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; private 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::EXTERNAL_IMAGE_URL_ENCODED;
public function setUp() : void public function setUp() : void
{ {
@ -174,52 +182,70 @@ public function test_set_setting_persist_correct_value_in_db_and_cache()
} }
#[Test] #[Test]
public function test_set_useEncryption_on_encrypts_all_accounts() public function test_set_useEncryption_On_encrypts_all_data()
{ {
Settings::set('useEncryption', true); Settings::set('useEncryption', true);
$twofaccounts = DB::table('twofaccounts')->get(); $twofaccounts = DB::table('twofaccounts')->get();
Icon::factory()->create();
$icons = DB::table('icons')->get();
$twofaccounts->each(function ($item, $key) { $twofaccounts->each(function ($item, $key) {
$this->assertEquals(self::ACCOUNT, Crypt::decryptString($item->account)); $this->assertEquals(self::ACCOUNT, Crypt::decryptString($item->account));
$this->assertEquals(self::SECRET, Crypt::decryptString($item->secret)); $this->assertEquals(self::SECRET, Crypt::decryptString($item->secret));
$this->assertEquals(self::TOTP_FULL_CUSTOM_URI, Crypt::decryptString($item->legacy_uri)); $this->assertEquals(self::TOTP_FULL_CUSTOM_URI, Crypt::decryptString($item->legacy_uri));
}); });
$icons->each(function ($item, $key) {
$this->assertEquals(OtpTestData::ICON_PNG_DATA, Crypt::decryptString($item->content));
});
} }
#[Test] #[Test]
public function test_set_useEncryption_on_twice_prevents_successive_encryption() public function test_set_useEncryption_On_twice_prevents_successive_encryption()
{ {
Settings::set('useEncryption', true); Settings::set('useEncryption', true);
Settings::set('useEncryption', true); Settings::set('useEncryption', true);
$twofaccounts = DB::table('twofaccounts')->get(); $twofaccounts = DB::table('twofaccounts')->get();
Icon::factory()->create();
$icons = DB::table('icons')->get();
$twofaccounts->each(function ($item, $key) { $twofaccounts->each(function ($item, $key) {
$this->assertEquals(self::ACCOUNT, Crypt::decryptString($item->account)); $this->assertEquals(self::ACCOUNT, Crypt::decryptString($item->account));
$this->assertEquals(self::SECRET, Crypt::decryptString($item->secret)); $this->assertEquals(self::SECRET, Crypt::decryptString($item->secret));
$this->assertEquals(self::TOTP_FULL_CUSTOM_URI, Crypt::decryptString($item->legacy_uri)); $this->assertEquals(self::TOTP_FULL_CUSTOM_URI, Crypt::decryptString($item->legacy_uri));
}); });
$icons->each(function ($item, $key) {
$this->assertEquals(OtpTestData::ICON_PNG_DATA, Crypt::decryptString($item->content));
});
} }
#[Test] #[Test]
public function test_set_useEncryption_off_decrypts_all_accounts() public function test_set_useEncryption_Off_decrypts_all_accounts()
{ {
Settings::set('useEncryption', true); Settings::set('useEncryption', true);
Settings::set('useEncryption', false); Settings::set('useEncryption', false);
$twofaccounts = DB::table('twofaccounts')->get(); $twofaccounts = DB::table('twofaccounts')->get();
Icon::factory()->create();
$icons = DB::table('icons')->get();
$twofaccounts->each(function ($item, $key) { $twofaccounts->each(function ($item, $key) {
$this->assertEquals(self::ACCOUNT, $item->account); $this->assertEquals(self::ACCOUNT, $item->account);
$this->assertEquals(self::SECRET, $item->secret); $this->assertEquals(self::SECRET, $item->secret);
$this->assertEquals(self::TOTP_FULL_CUSTOM_URI, $item->legacy_uri); $this->assertEquals(self::TOTP_FULL_CUSTOM_URI, $item->legacy_uri);
}); });
$icons->each(function ($item, $key) {
$this->assertEquals(OtpTestData::ICON_PNG_DATA, $item->content);
});
} }
#[Test] #[Test]
#[DataProvider('provideUndecipherableData')] #[DataProvider('provideUndecipherableData')]
public function test_set_useEncryption_off_returns_exception_when_data_are_undecipherable(array $data) public function test_set_useEncryption_Off_returns_exception_when_data_are_undecipherable(array $data)
{ {
$this->expectException(\App\Exceptions\DbEncryptionException::class); $this->expectException(\App\Exceptions\DbEncryptionException::class);
@ -252,29 +278,6 @@ public static function provideUndecipherableData() : array
]; ];
} }
#[Test]
public function test_set_array_of_settings_persist_correct_values()
{
$value = Settings::set([
self::SETTING_NAME => self::SETTING_VALUE_STRING,
self::SETTING_NAME_ALT => self::SETTING_VALUE_INT,
]);
$cached = Cache::get(SettingService::CACHE_ITEM_NAME); // returns a Collection
$this->assertDatabaseHas('options', [
self::KEY => self::SETTING_NAME,
self::VALUE => self::SETTING_VALUE_STRING,
]);
$this->assertDatabaseHas('options', [
self::KEY => self::SETTING_NAME_ALT,
self::VALUE => self::SETTING_VALUE_INT,
]);
$this->assertEquals($cached->get(self::SETTING_NAME), self::SETTING_VALUE_STRING);
$this->assertEquals($cached->get(self::SETTING_NAME_ALT), self::SETTING_VALUE_INT);
}
#[Test] #[Test]
public function test_set_true_setting_persist_transformed_boolean() public function test_set_true_setting_persist_transformed_boolean()
{ {
@ -366,4 +369,45 @@ public function test_cache_is_updated_when_setting_is_deleted()
Cache::shouldHaveReceived('put'); Cache::shouldHaveReceived('put');
} }
#[Test]
public function test_set_storeIconsInDatabase_setting_dispatches_storeIconsInDatabaseSettingChanged()
{
Event::fake([
storeIconsInDatabaseSettingChanged::class,
]);
Settings::set('storeIconsInDatabase', true);
Event::assertDispatched(storeIconsInDatabaseSettingChanged::class);
}
#[Test]
public function test_set_storeIconsInDatabase_setting_impacts_the_icon_store()
{
Settings::set('storeIconsInDatabase', false);
$this->assertFalse(IconStore::usesDatabase());
Settings::set('storeIconsInDatabase', true);
$this->assertTrue(IconStore::usesDatabase());
}
#[Test]
public function test_set_storeIconsInDatabase_is_cancelled_if_database_toggling_failed()
{
$this->expectException(FailedIconStoreDatabaseTogglingException::class);
$newValue = true;
IconStore::shouldReceive('setDatabaseReplication')
->once()
->with($newValue)
->andThrow(FailedIconStoreDatabaseTogglingException::class);
Settings::set('storeIconsInDatabase', $newValue);
$this->assertFalse(Settings::get('storeIconsInDatabase'));
}
} }

View File

@ -0,0 +1,24 @@
<?php
namespace Tests\Unit\Events;
use App\Events\storeIconsInDatabaseSettingChanged;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* storeIconsInDatabaseSettingChangedTest test class
*/
#[CoversClass(storeIconsInDatabaseSettingChanged::class)]
class storeIconsInDatabaseSettingChangedTest extends TestCase
{
#[Test]
public function test_event_constructor()
{
$newValue = true;
$event = new storeIconsInDatabaseSettingChanged($newValue);
$this->assertSame($newValue, $event->newValue);
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace Tests\Unit;
use App\Models\Icon;
use App\Models\TwoFAccount;
use App\Services\SettingService;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Crypt;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\Data\OtpTestData;
use Tests\ModelTestCase;
/**
* IconModelTest test class
*/
#[CoversClass(Icon::class)]
class IconModelTest extends ModelTestCase
{
#[Test]
public function test_model_configuration()
{
$this->runConfigurationAssertions(
new Icon,
['name'],
['created_at', 'updated_at'],
['*'],
[],
[],
[],
['created_at', 'updated_at'],
\Illuminate\Database\Eloquent\Collection::class,
'icons',
'name',
false
);
}
#[Test]
#[DataProvider('provideSensitiveAttributes')]
public function test_sensitive_attributes_are_stored_encrypted(string $attribute)
{
$settingService = $this->mock(SettingService::class, function (MockInterface $settingService) {
$settingService->shouldReceive('get')
->with('useEncryption')
->andReturn(true);
});
$icon = Icon::factory()->make([
$attribute => base64_decode(OtpTestData::ICON_PNG_DATA),
]);
$this->assertEquals(OtpTestData::ICON_PNG_DATA, Crypt::decryptString($icon->getAttributes()[$attribute]));
$this->forgetMock(SettingService::class);
}
/**
* Provide attributes to test for encryption
*/
public static function provideSensitiveAttributes() : array
{
return [
[
'content',
],
];
}
#[Test]
#[DataProvider('provideSensitiveAttributes')]
public function test_sensitive_attributes_are_returned_clear(string $attribute)
{
$settingService = $this->mock(SettingService::class, function (MockInterface $settingService) {
$settingService->shouldReceive('get')
->with('useEncryption')
->andReturn(false);
});
$icon = Icon::factory()->make([
$attribute => base64_decode(OtpTestData::ICON_PNG_DATA),
]);
$this->assertEquals($icon->getAttributes()[$attribute], base64_encode($icon->$attribute));
$this->forgetMock(SettingService::class);
}
#[Test]
#[DataProvider('provideSensitiveAttributes')]
public function test_indecipherable_attributes_returns_masked_value(string $attribute)
{
$settingService = $this->mock(SettingService::class, function (MockInterface $settingService) {
$settingService->shouldReceive('get')
->with('useEncryption')
->andReturn(true);
});
Crypt::shouldReceive('encryptString')
->andReturn('indecipherableString');
$icon = Icon::factory()->make();
$this->assertEquals(__('errors.indecipherable'), $icon->$attribute);
$this->forgetMock(SettingService::class);
}
#[Test]
public function test_twofaccount_relation()
{
$model = new TwoFAccount;
$relation = $model->iconResource();
$this->assertInstanceOf(HasOne::class, $relation);
$this->assertEquals('name', $relation->getForeignKeyName());
}
}

View File

@ -3,11 +3,13 @@
namespace Tests\Unit\Listeners; namespace Tests\Unit\Listeners;
use App\Events\TwoFAccountDeleted; use App\Events\TwoFAccountDeleted;
use App\Facades\Settings;
use App\Listeners\CleanIconStorage; use App\Listeners\CleanIconStorage;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Services\IconStoreService;
use App\Services\SettingService; use App\Services\SettingService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface; use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@ -20,22 +22,33 @@
class CleanIconStorageTest extends TestCase class CleanIconStorageTest extends TestCase
{ {
#[Test] #[Test]
public function test_it_deletes_icon_file_using_storage_facade() public function test_it_deletes_icon_file_using_the_iconstore()
{ {
$settingService = $this->mock(SettingService::class, function (MockInterface $settingService) { // TODO : Reuse the following mock as a global read-only
$settingService->shouldReceive('get') // SettingService mock for all tests, or create a dedicated stub
->andReturn(false); $this->mock(SettingService::class, function (MockInterface $iconStore) {
foreach (config('2fauth.settings') as $setting => $value) {
$iconStore->shouldReceive('get')
->with($setting)
->andReturn($value);
}
}); });
$twofaccount = TwoFAccount::factory()->make(); $twofaccount = TwoFAccount::factory()->make();
$event = new TwoFAccountDeleted($twofaccount); $event = new TwoFAccountDeleted($twofaccount);
$listener = new CleanIconStorage; $listener = App::make(CleanIconStorage::class);
Storage::shouldReceive('disk->delete') $mockedIconStore = $this->mock(IconStoreService::class, function (MockInterface $iconStore) use ($event) {
->with($event->twofaccount->icon) $iconStore->shouldReceive('delete')
->andReturn(true); ->once()
->with($event->twofaccount->icon)
$this->assertNull($listener->handle($event)); ->andReturn(true);
});
/**
* @disregard P1009 Undefined type
*/
$this->assertNull($listener->handle($event, $mockedIconStore));
} }
#[Test] #[Test]

View File

@ -0,0 +1,28 @@
<?php
namespace Tests\Unit\Listeners;
use App\Events\storeIconsInDatabaseSettingChanged;
use App\Listeners\ToggleIconReplicationToDatabase;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* ToggleIconReplicationToDatabaseTest test class
*/
#[CoversClass(ToggleIconReplicationToDatabase::class)]
class ToggleIconReplicationToDatabaseTest extends TestCase
{
#[Test]
public function test_ToggleIconReplicationToDatabase_listen_to_storeIconsInDatabaseSettingChanged_event()
{
Event::fake();
Event::assertListening(
storeIconsInDatabaseSettingChanged::class,
ToggleIconReplicationToDatabase::class
);
}
}

View File

@ -5,6 +5,8 @@
use App\Exceptions\EncryptedMigrationException; use App\Exceptions\EncryptedMigrationException;
use App\Exceptions\InvalidMigrationDataException; use App\Exceptions\InvalidMigrationDataException;
use App\Exceptions\UnsupportedMigrationException; use App\Exceptions\UnsupportedMigrationException;
use App\Facades\Icons;
use App\Facades\IconStore;
use App\Factories\MigratorFactory; use App\Factories\MigratorFactory;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Providers\MigrationServiceProvider; use App\Providers\MigrationServiceProvider;
@ -72,10 +74,12 @@ public function setUp() : void
{ {
parent::setUp(); parent::setUp();
$this->mock(SettingService::class, function (MockInterface $settingService) { $this->mock(SettingService::class, function (MockInterface $iconStore) {
$settingService->allows() foreach (config('2fauth.settings') as $setting => $value) {
->get('useEncryption') $iconStore->shouldReceive('get')
->andReturn(false); ->with($setting)
->andReturn($value);
}
}); });
$this->totpTwofaccount = new TwoFAccount; $this->totpTwofaccount = new TwoFAccount;
@ -360,6 +364,7 @@ public function test_migrate_gauth_returns_fake_accounts()
#[DataProvider('AegisWithIconMigrationProvider')] #[DataProvider('AegisWithIconMigrationProvider')]
public function test_migrate_aegis_payload_with_icon_sets_and_stores_the_icon($migration) public function test_migrate_aegis_payload_with_icon_sets_and_stores_the_icon($migration)
{ {
Icons::spy();
Storage::fake('icons'); Storage::fake('icons');
$migrator = new AegisMigrator; $migrator = new AegisMigrator;
@ -368,6 +373,7 @@ public function test_migrate_aegis_payload_with_icon_sets_and_stores_the_icon($m
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts); $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
$this->assertCount(1, $accounts); $this->assertCount(1, $accounts);
Icons::shouldHaveReceived('buildFromResource')->once();
Storage::disk('icons')->assertExists($accounts->first()->icon); Storage::disk('icons')->assertExists($accounts->first()->icon);
} }
@ -408,14 +414,16 @@ public function test_migrate_aegis_payload_with_unsupported_icon_does_not_fail()
#[DataProvider('TwoFAuthWithIconMigrationProvider')] #[DataProvider('TwoFAuthWithIconMigrationProvider')]
public function test_migrate_2fauth_payload_with_icon_sets_and_stores_the_icon($migration) public function test_migrate_2fauth_payload_with_icon_sets_and_stores_the_icon($migration)
{ {
Icons::spy();
Storage::fake('icons'); Storage::fake('icons');
$migrator = new TwoFAuthMigrator; $migrator = new TwoFAuthMigrator;
$accounts = $migrator->migrate($migration); $accounts = $migrator->migrate($migration);
$this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts); $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts);
$this->assertCount(1, $accounts); $this->assertCount(1, $accounts);
Icons::shouldHaveReceived('buildFromResource')->once();
Storage::disk('icons')->assertExists($accounts->first()->icon); Storage::disk('icons')->assertExists($accounts->first()->icon);
} }

View File

@ -4,6 +4,7 @@
use App\Events\TwoFAccountDeleted; use App\Events\TwoFAccountDeleted;
use App\Helpers\Helpers; use App\Helpers\Helpers;
use App\Models\Icon;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Services\SettingService; use App\Services\SettingService;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -144,4 +145,14 @@ public function test_user_relation()
$this->assertInstanceOf(BelongsTo::class, $relation); $this->assertInstanceOf(BelongsTo::class, $relation);
$this->assertEquals('user_id', $relation->getForeignKeyName()); $this->assertEquals('user_id', $relation->getForeignKeyName());
} }
#[Test]
public function test_twofaccount_relation()
{
$model = new Icon();
$relation = $model->twofaccount();
$this->assertInstanceOf(BelongsTo::class, $relation);
$this->assertEquals('name', $relation->getForeignKeyName());
}
} }