diff --git a/app/Domains/Auth/Controllers/ScannerLoginController.php b/app/Domains/Auth/Controllers/ScannerLoginController.php index 95cec5c..3a6b446 100644 --- a/app/Domains/Auth/Controllers/ScannerLoginController.php +++ b/app/Domains/Auth/Controllers/ScannerLoginController.php @@ -5,7 +5,6 @@ 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\Domains\Authorization\Enums\RoleCode; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -23,7 +22,6 @@ class ScannerLoginController extends Controller $credentials['password'], $request->ip(), $request->userAgent(), - RoleCode::from($credentials['rol_codigo'] ?? RoleCode::Scanner->value), ); $expirationMinutes = (int) config('sanctum.expiration'); diff --git a/app/Domains/Auth/Requests/ScannerLoginRequest.php b/app/Domains/Auth/Requests/ScannerLoginRequest.php index 3e4c80b..c699282 100644 --- a/app/Domains/Auth/Requests/ScannerLoginRequest.php +++ b/app/Domains/Auth/Requests/ScannerLoginRequest.php @@ -2,16 +2,4 @@ namespace App\Domains\Auth\Requests; -use App\Domains\Authorization\Enums\RoleCode; -use Illuminate\Validation\Rule; - -class ScannerLoginRequest extends AdminAppLoginRequest -{ - public function rules(): array - { - return [ - ...parent::rules(), - 'rol_codigo' => ['sometimes', Rule::in([RoleCode::Scanner->value, RoleCode::AdminApp->value])], - ]; - } -} +class ScannerLoginRequest extends AdminAppLoginRequest {} diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php index fdb2b86..0fa8122 100644 --- a/app/Domains/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -78,7 +78,6 @@ class PasswordLoginService string $password, ?string $ipAddress, ?string $userAgent, - RoleCode $role = RoleCode::Scanner, ): User { return $this->authenticateUser( $email, @@ -86,12 +85,10 @@ class PasswordLoginService null, $ipAddress, $userAgent, - $role, + RoleCode::Scanner, true, PermissionCode::ScanTickets->value, - $role === RoleCode::AdminApp - ? PasswordResetRequested::CHANNEL_ADMINAPP - : PasswordResetRequested::CHANNEL_SCANNER, + PasswordResetRequested::CHANNEL_SCANNER, ); } diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php index ffccae9..fc47b61 100644 --- a/app/Domains/Ticket/Services/ScannerTicketService.php +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -3,7 +3,6 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; -use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -173,7 +172,6 @@ class ScannerTicketService private function requiresCategoryValidation(User $scanner): bool { - return $scanner->rol_codigo !== RoleCode::AdminApp->value - && $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation(); + return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation(); } } diff --git a/app/Http/Middleware/EnsureScannerTenant.php b/app/Http/Middleware/EnsureScannerTenant.php index 09e7a18..3cab1f7 100644 --- a/app/Http/Middleware/EnsureScannerTenant.php +++ b/app/Http/Middleware/EnsureScannerTenant.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Domains\Authorization\Enums\PermissionCode; +use App\Domains\Authorization\Enums\RoleCode; use Closure; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -19,6 +20,7 @@ class EnsureScannerTenant if ( ! $user + || $user->rol_codigo !== RoleCode::Scanner->value || ! $user->tenant_codigo || ! $user->hasPermission(PermissionCode::ScanTickets->value) ) { diff --git a/database/migrations/2026_09_04_010000_remove_scan_permission_from_adminapp_role.php b/database/migrations/2026_09_04_010000_remove_scan_permission_from_adminapp_role.php new file mode 100644 index 0000000..10c4f9b --- /dev/null +++ b/database/migrations/2026_09_04_010000_remove_scan_permission_from_adminapp_role.php @@ -0,0 +1,42 @@ +where('rol_codigo', RoleCode::AdminApp->value) + ->where('codigo_permiso', PermissionCode::ScanTickets->value) + ->delete(); + } + + public function down(): void + { + $roleExists = DB::table('roles') + ->where('codigo', RoleCode::AdminApp->value) + ->exists(); + $permissionExists = DB::table('permisos') + ->where('codigo', PermissionCode::ScanTickets->value) + ->exists(); + + if (! $roleExists || ! $permissionExists) { + return; + } + + DB::table('roles_permisos')->updateOrInsert( + [ + 'rol_codigo' => RoleCode::AdminApp->value, + 'codigo_permiso' => PermissionCode::ScanTickets->value, + ], + [ + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } +}; diff --git a/database/seeders/AuthorizationSeeder.php b/database/seeders/AuthorizationSeeder.php index 344f05d..3764942 100644 --- a/database/seeders/AuthorizationSeeder.php +++ b/database/seeders/AuthorizationSeeder.php @@ -120,7 +120,7 @@ class AuthorizationSeeder extends Seeder RoleCode::AdminApp->value => [ 'nombre' => 'Administrador de la aplicación', 'descripcion' => 'Accede a los menús administrativos de la aplicación.', - 'permisos' => [PermissionCode::ScanTickets->value], + 'permisos' => [], ], RoleCode::Scanner->value => [ 'nombre' => 'Scanner', diff --git a/docs/email-identity.md b/docs/email-identity.md index 0b3cb38..c7a9c98 100644 --- a/docs/email-identity.md +++ b/docs/email-identity.md @@ -18,9 +18,8 @@ Login y recuperación seleccionan la identidad de la aplicación: - Tienda y Google: `user`. - AdminApp: `adminapp`. -- Scanner: `scanner` por defecto. El login acepta `rol_codigo=adminapp` para - administradores con permiso de escaneo; el permiso sigue siendo obligatorio. - Esas cuentas recuperan su contraseña mediante AdminApp. +- Scanner: `scanner`. Sólo las identidades con ese rol pueden autenticarse y + consumir los endpoints del scanner. - Verificación administrativa de plataforma: `admin`. Los endpoints de validación de código y cambio de contraseña toman el rol de diff --git a/tests/Feature/Auth/ScannerLoginControllerTest.php b/tests/Feature/Auth/ScannerLoginControllerTest.php index ab29952..6bb37bc 100644 --- a/tests/Feature/Auth/ScannerLoginControllerTest.php +++ b/tests/Feature/Auth/ScannerLoginControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Auth; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\LoginAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; @@ -14,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use Tests\TestCase; class ScannerLoginControllerTest extends TestCase @@ -23,19 +25,15 @@ class ScannerLoginControllerTest extends TestCase public function test_it_logs_in_a_tenant_bound_user_with_scan_permission(): void { $role = Role::query()->create([ - 'codigo' => RoleCode::AdminApp->value, - 'nombre' => 'Operador', + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', ]); $permission = Permission::query()->create([ 'codigo' => PermissionCode::ScanTickets->value, 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); - $tenant = Tenant::query()->create([ - 'codigo' => 'acme', - 'nombre' => 'Acme', - 'dominio' => 'acme.test', - ]); + $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('secret123'), @@ -46,29 +44,90 @@ class ScannerLoginControllerTest extends TestCase $response = $this->postJson('/api/v1/scanner/login', [ 'email' => ' SCANNER@EXAMPLE.COM ', 'password' => 'secret123', - 'rol_codigo' => RoleCode::AdminApp->value, ]); $response ->assertOk() ->assertJsonPath('user.id', $user->id) - ->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value) + ->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value) ->assertJsonPath('token_type', 'Bearer'); $this->assertSame(['scanner'], $user->tokens()->sole()->abilities); } + public function test_it_rejects_adminapp_credentials_even_when_the_role_has_scan_permission(): void + { + $role = Role::query()->create([ + 'codigo' => RoleCode::AdminApp->value, + 'nombre' => 'Administrador', + ]); + $permission = Permission::query()->create([ + 'codigo' => PermissionCode::ScanTickets->value, + 'nombre' => 'Escanear tickets', + ]); + $role->permissions()->attach($permission->codigo); + $tenant = $this->createTenant(); + $adminApp = User::factory()->create([ + 'email' => 'shared@example.com', + 'password' => Hash::make('admin-password'), + 'rol_codigo' => $role->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->postJson('/api/v1/scanner/login', [ + 'email' => $adminApp->email, + 'password' => 'admin-password', + 'rol_codigo' => RoleCode::AdminApp->value, + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_shared_email_authenticates_the_scanner_identity_only(): void + { + $scannerRole = Role::query()->create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $adminAppRole = Role::query()->create([ + 'codigo' => RoleCode::AdminApp->value, + 'nombre' => 'Administrador', + ]); + $permission = Permission::query()->create([ + 'codigo' => PermissionCode::ScanTickets->value, + 'nombre' => 'Escanear tickets', + ]); + $scannerRole->permissions()->attach($permission->codigo); + $tenant = $this->createTenant(); + User::factory()->create([ + 'email' => 'shared@example.com', + 'password' => Hash::make('admin-password'), + 'rol_codigo' => $adminAppRole->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + $scanner = User::factory()->create([ + 'email' => 'shared@example.com', + 'password' => Hash::make('scanner-password'), + 'rol_codigo' => $scannerRole->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + + $this->postJson('/api/v1/scanner/login', [ + 'email' => 'shared@example.com', + 'password' => 'scanner-password', + ]) + ->assertOk() + ->assertJsonPath('user.id', $scanner->id) + ->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value); + } + public function test_it_rejects_a_user_without_scan_permission(): void { $role = Role::query()->create([ 'codigo' => RoleCode::Scanner->value, 'nombre' => 'Scanner sin permiso', ]); - $tenant = Tenant::query()->create([ - 'codigo' => 'acme', - 'nombre' => 'Acme', - 'dominio' => 'acme.test', - ]); + $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'customer@example.com', 'password' => Hash::make('secret123'), @@ -93,11 +152,7 @@ class ScannerLoginControllerTest extends TestCase 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); - $tenant = Tenant::query()->create([ - 'codigo' => 'acme', - 'nombre' => 'Acme', - 'dominio' => 'acme.test', - ]); + $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('correct-password'), @@ -135,11 +190,7 @@ class ScannerLoginControllerTest extends TestCase 'nombre' => 'Escanear tickets', ]); $role->permissions()->attach($permission->codigo); - $tenant = Tenant::query()->create([ - 'codigo' => 'acme', - 'nombre' => 'Acme', - 'dominio' => 'acme.test', - ]); + $tenant = $this->createTenant(); $user = User::factory()->create([ 'email' => 'scanner@example.com', 'password' => Hash::make('correct-password'), @@ -164,4 +215,30 @@ class ScannerLoginControllerTest extends TestCase && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, ); } + + private function createTenant(): Tenant + { + $logo = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tests/scanner-login-logo.png', + 'filename' => 'scanner-login-logo.png', + 'type' => 'image', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 1, + ]); + + return Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + ]); + } } diff --git a/tests/Feature/Auth/ScannerMeControllerTest.php b/tests/Feature/Auth/ScannerMeControllerTest.php index 830fb8d..c5ddec5 100644 --- a/tests/Feature/Auth/ScannerMeControllerTest.php +++ b/tests/Feature/Auth/ScannerMeControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Auth; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\RoleCode; @@ -10,6 +11,7 @@ use App\Domains\Authorization\Models\Role; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -28,11 +30,7 @@ class ScannerMeControllerTest extends TestCase 'nombre' => 'Escanear tickets', ]); $scannerRole->permissions()->attach($permission->codigo); - $tenant = Tenant::query()->create([ - 'codigo' => 'acme', - 'nombre' => 'Acme', - 'dominio' => 'acme.test', - ]); + $tenant = $this->createTenant(); $home = Menu::query()->create([ 'code' => 'scanner.inicio', 'label' => 'Inicio', @@ -65,4 +63,51 @@ class ScannerMeControllerTest extends TestCase ->assertJsonCount(2, 'data.tenant.menues') ->assertJsonMissing(['code' => $foreign->code]); } + + public function test_it_rejects_an_adminapp_user_even_with_scan_permission(): void + { + $adminAppRole = Role::query()->create([ + 'codigo' => RoleCode::AdminApp->value, + 'nombre' => 'Administrador', + ]); + $permission = Permission::query()->create([ + 'codigo' => PermissionCode::ScanTickets->value, + 'nombre' => 'Escanear tickets', + ]); + $adminAppRole->permissions()->attach($permission->codigo); + $tenant = $this->createTenant(); + $adminApp = User::factory()->create([ + 'rol_codigo' => $adminAppRole->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + Sanctum::actingAs($adminApp); + + $this->getJson('/api/v1/scanner/me')->assertForbidden(); + } + + private function createTenant(): Tenant + { + $logo = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tests/scanner-me-logo.png', + 'filename' => 'scanner-me-logo.png', + 'type' => 'image', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 1, + ]); + + return Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + ]); + } } diff --git a/tests/Feature/Seeders/AuthorizationSeederTest.php b/tests/Feature/Seeders/AuthorizationSeederTest.php index 0889820..4fe9976 100644 --- a/tests/Feature/Seeders/AuthorizationSeederTest.php +++ b/tests/Feature/Seeders/AuthorizationSeederTest.php @@ -27,7 +27,7 @@ class AuthorizationSeederTest extends TestCase ], Role::query()->orderBy('codigo')->pluck('codigo')->all() ); - $this->assertCount(22, Permission::query()->get()); + $this->assertCount(23, Permission::query()->get()); } public function test_it_assigns_the_expected_permissions_to_each_role(): void @@ -39,7 +39,7 @@ class AuthorizationSeederTest extends TestCase $scanner = Role::query()->where('codigo', RoleCode::Scanner->value)->firstOrFail(); $user = Role::query()->where('codigo', RoleCode::User->value)->firstOrFail(); - $this->assertCount(22, $admin->permissions); + $this->assertCount(23, $admin->permissions); $this->assertCount(0, $appAdmin->permissions); $this->assertSame( [PermissionCode::ScanTickets->value], @@ -54,7 +54,7 @@ class AuthorizationSeederTest extends TestCase $this->seed(AuthorizationSeeder::class); $this->assertCount(4, Role::query()->get()); - $this->assertCount(22, Permission::query()->get()); - $this->assertDatabaseCount('roles_permisos', 23); + $this->assertCount(23, Permission::query()->get()); + $this->assertDatabaseCount('roles_permisos', 24); } } diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php index 2993858..0ed42df 100644 --- a/tests/Feature/Ticket/ScannerTicketControllerTest.php +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Ticket; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; +use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; @@ -300,42 +301,19 @@ class ScannerTicketControllerTest extends TestCase ->assertNotFound(); } - public function test_adminapp_can_read_and_scan_all_tenant_categories_without_assignments(): void + public function test_adminapp_cannot_access_scanner_routes_even_with_scan_permission(): void { - $this->tenant->update(['scanner_category_validation_enabled' => true]); $admin = User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $this->tenant->codigo, ]); - $this->assertCount(0, $admin->scanCategories); - $otherCategory = Category::query()->create([ - 'tenant_code' => $this->tenant->codigo, 'nombre' => 'Comidas', - ]); + $admin->role()->firstOrFail()->permissions()->attach(PermissionCode::ScanTickets->value); + $ticket = $this->createTicket((string) Str::uuid()); Sanctum::actingAs($admin); - foreach ([$this->category, $otherCategory] as $category) { - $ticket = $this->createTicket((string) Str::uuid(), [], $category); - $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertOk(); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") - ->assertOk()->assertJsonPath('data.scanner_user_id', $admin->id); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") - ->assertUnprocessable()->assertJsonValidationErrors('ticket'); - } - } - public function test_adminapp_cannot_read_or_scan_another_tenants_ticket(): void - { - $admin = User::factory()->create([ - 'rol_codigo' => RoleCode::AdminApp->value, - 'tenant_codigo' => $this->tenant->codigo, - ]); - $foreignTenant = $this->createTenant('foreign'); - $foreignCategory = Category::query()->create([ - 'tenant_code' => $foreignTenant->codigo, 'nombre' => 'Externas', - ]); - $ticket = $this->createTicket((string) Str::uuid(), [], $foreignCategory, $foreignTenant); - Sanctum::actingAs($admin); - $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertNotFound(); - $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertNotFound(); + $this->getJson('/api/v1/scanner/tickets')->assertForbidden(); + $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertForbidden(); + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertForbidden(); $this->assertNull($ticket->fresh()->used_at); }