feat(scanner): add scanner authentication and context services with routes and tests
This commit is contained in:
42
app/Domains/Auth/Controllers/ScannerLoginController.php
Normal file
42
app/Domains/Auth/Controllers/ScannerLoginController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\ScannerLoginRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ScannerLoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
public function __invoke(ScannerLoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = $this->passwordLoginService->authenticateScanner(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
'scanner-token',
|
||||
['scanner'],
|
||||
now()->addMinutes($expirationMinutes),
|
||||
)->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Domains/Auth/Controllers/ScannerMeController.php
Normal file
24
app/Domains/Auth/Controllers/ScannerMeController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Resources\ScannerMeResource;
|
||||
use App\Domains\Auth\Services\ScannerContextService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ScannerMeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScannerContextService $scannerContextService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): ScannerMeResource
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return ScannerMeResource::make($this->scannerContextService->load($user));
|
||||
}
|
||||
}
|
||||
5
app/Domains/Auth/Requests/ScannerLoginRequest.php
Normal file
5
app/Domains/Auth/Requests/ScannerLoginRequest.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
class ScannerLoginRequest extends AdminAppLoginRequest {}
|
||||
21
app/Domains/Auth/Resources/ScannerMeResource.php
Normal file
21
app/Domains/Auth/Resources/ScannerMeResource.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Resources;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin User */
|
||||
class ScannerMeResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'user' => UserResource::make($this->resource),
|
||||
'tenant' => TenantResource::make($this->tenant),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,29 @@ class PasswordLoginService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a tenant-bound scanner without requiring its tenant code.
|
||||
*
|
||||
* @throws AccountLockedException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticateScanner(
|
||||
string $email,
|
||||
string $password,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): User {
|
||||
return $this->authenticateUser(
|
||||
$email,
|
||||
$password,
|
||||
null,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
RoleCode::Scanner,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private function authenticateUser(
|
||||
string $email,
|
||||
string $password,
|
||||
@@ -210,7 +233,7 @@ class PasswordLoginService
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to trigger reset password on account lock', [
|
||||
'user_id' => $user->id,
|
||||
'exception' => $e
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Domains/Auth/Services/ScannerContextService.php
Normal file
25
app/Domains/Auth/Services/ScannerContextService.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
|
||||
class ScannerContextService
|
||||
{
|
||||
public function load(User $user): User
|
||||
{
|
||||
$tenant = $user->tenant()
|
||||
->with([
|
||||
'menues' => fn ($query) => $query->whereHas(
|
||||
'roles',
|
||||
fn ($query) => $query->where('codigo', RoleCode::Scanner->value)
|
||||
),
|
||||
])
|
||||
->firstOrFail();
|
||||
|
||||
$user->setRelation('tenant', $tenant);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,4 @@ Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
require __DIR__.'/scanner.php';
|
||||
|
||||
11
app/Domains/Auth/routes/scanner.php
Normal file
11
app/Domains/Auth/routes/scanner.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Controllers\ScannerLoginController;
|
||||
use App\Domains\Auth\Controllers\ScannerMeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/scanner')->group(function (): void {
|
||||
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
|
||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||
->get('me', ScannerMeController::class);
|
||||
});
|
||||
@@ -29,6 +29,13 @@ class MenuSeeder extends Seeder
|
||||
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||
'route' => '/',
|
||||
],
|
||||
['code' => 'scanner.inicio', 'label' => 'Inicio', 'route' => '/scanner/inicio'],
|
||||
['code' => 'scanner.scan', 'label' => 'Escanear', 'route' => '/scanner/scan'],
|
||||
[
|
||||
'code' => 'scanner.detail',
|
||||
'label' => 'Detalle',
|
||||
'route' => '/scanner/detail/:id',
|
||||
],
|
||||
[
|
||||
'code' => 'adminapp.event',
|
||||
'label' => 'Eventos',
|
||||
@@ -219,8 +226,11 @@ class MenuSeeder extends Seeder
|
||||
->where('code', 'main.adminapp')
|
||||
->orWhere('parent_menu_code', 'main.adminapp')
|
||||
->pluck('code');
|
||||
$scannerMenuCodes = Menu::query()
|
||||
->where('code', 'like', 'scanner.%')
|
||||
->pluck('code');
|
||||
$userMenuCodes = Menu::query()
|
||||
->whereNotIn('code', $adminAppMenuCodes)
|
||||
->whereNotIn('code', $adminAppMenuCodes->merge($scannerMenuCodes))
|
||||
->pluck('code');
|
||||
|
||||
Role::query()
|
||||
@@ -231,6 +241,10 @@ class MenuSeeder extends Seeder
|
||||
->where('codigo', RoleCode::User->value)
|
||||
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
|
||||
|
||||
Role::query()
|
||||
->where('codigo', RoleCode::Scanner->value)
|
||||
->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes));
|
||||
|
||||
$tenants = Tenant::all();
|
||||
|
||||
$allMenus = Menu::pluck('code')->toArray();
|
||||
|
||||
66
tests/Feature/Auth/ScannerLoginControllerTest.php
Normal file
66
tests/Feature/Auth/ScannerLoginControllerTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerLoginControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_logs_in_a_tenant_bound_scanner(): void
|
||||
{
|
||||
$role = Role::query()->create([
|
||||
'codigo' => RoleCode::Scanner->value,
|
||||
'nombre' => 'Scanner',
|
||||
]);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'scanner@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
'rol_codigo' => $role->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/scanner/login', [
|
||||
'email' => ' SCANNER@EXAMPLE.COM ',
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $user->id)
|
||||
->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value)
|
||||
->assertJsonPath('token_type', 'Bearer');
|
||||
|
||||
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_non_scanner_user(): void
|
||||
{
|
||||
$role = Role::query()->create([
|
||||
'codigo' => RoleCode::User->value,
|
||||
'nombre' => 'Usuario',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'customer@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
'rol_codigo' => $role->codigo,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/scanner/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
}
|
||||
}
|
||||
61
tests/Feature/Auth/ScannerMeControllerTest.php
Normal file
61
tests/Feature/Auth/ScannerMeControllerTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScannerMeControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_only_scanner_menus_assigned_to_the_tenant(): void
|
||||
{
|
||||
$scannerRole = Role::query()->create([
|
||||
'codigo' => RoleCode::Scanner->value,
|
||||
'nombre' => 'Scanner',
|
||||
]);
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.test',
|
||||
]);
|
||||
$home = Menu::query()->create([
|
||||
'code' => 'scanner.inicio',
|
||||
'label' => 'Inicio',
|
||||
'route' => '/scanner/inicio',
|
||||
]);
|
||||
$scan = Menu::query()->create([
|
||||
'code' => 'scanner.scan',
|
||||
'label' => 'Escanear',
|
||||
'route' => '/scanner/scan',
|
||||
]);
|
||||
$foreign = Menu::query()->create([
|
||||
'code' => 'adminapp.inicio',
|
||||
'label' => 'Administración',
|
||||
'route' => '/admin/inicio',
|
||||
]);
|
||||
|
||||
$scannerRole->menus()->sync([$home->code, $scan->code]);
|
||||
$tenant->menues()->sync([$home->code, $scan->code, $foreign->code]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'rol_codigo' => $scannerRole->codigo,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->getJson('/api/v1/scanner/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.user.id', $user->id)
|
||||
->assertJsonPath('data.tenant.codigo', $tenant->codigo)
|
||||
->assertJsonCount(2, 'data.tenant.menues')
|
||||
->assertJsonMissing(['code' => $foreign->code]);
|
||||
}
|
||||
}
|
||||
@@ -124,8 +124,13 @@ class MenuSeederTest extends TestCase
|
||||
->where('code', 'main.adminapp')
|
||||
->orWhere('parent_menu_code', 'main.adminapp')
|
||||
->pluck('code');
|
||||
$scannerMenuCodes = Menu::query()
|
||||
->where('code', 'like', 'scanner.%')
|
||||
->orderBy('code')
|
||||
->pluck('code')
|
||||
->all();
|
||||
$userMenuCodes = Menu::query()
|
||||
->whereNotIn('code', $adminAppMenuCodes)
|
||||
->whereNotIn('code', [...$adminAppMenuCodes, ...$scannerMenuCodes])
|
||||
->orderBy('code')
|
||||
->pluck('code')
|
||||
->all();
|
||||
@@ -153,9 +158,23 @@ class MenuSeederTest extends TestCase
|
||||
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
||||
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
||||
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
|
||||
|
||||
$scannerRoleMenuCodes = Role::query()
|
||||
->where('codigo', RoleCode::Scanner->value)
|
||||
->firstOrFail()
|
||||
->menus()
|
||||
->orderBy('menues.code')
|
||||
->pluck('menues.code')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
'scanner.detail',
|
||||
'scanner.inicio',
|
||||
'scanner.scan',
|
||||
], $scannerRoleMenuCodes);
|
||||
$this->assertDatabaseCount(
|
||||
'roles_menues',
|
||||
(count($allMenuCodes) * 2) + count($userMenuCodes)
|
||||
(count($allMenuCodes) * 2) + count($userMenuCodes) + count($scannerMenuCodes)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user