id(); $table->string('codigo')->unique(); $table->string('nombre'); $table->string('dominio'); $table->string('search_product_layout')->default('column_with_image'); $table->string('search_group_layout')->default('paginated'); }); Schema::create('events', function (Blueprint $table): void { $table->id(); $table->string('tenant_code'); $table->string('title'); $table->timestamps(); }); Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('nombre_apellido'); $table->string('email'); $table->string('active_email')->virtualAs('CASE WHEN deleted_at IS NULL THEN lower(email) ELSE NULL END'); $table->string('password'); $table->string('rol_codigo')->default('user'); $table->string('tenant_codigo')->nullable(); $table->softDeletes(); $table->timestamps(); $table->unique(['active_email', 'rol_codigo']); }); Schema::create('menues', function (Blueprint $table): void { $table->id(); $table->string('code')->unique(); $table->string('label'); $table->string('route'); $table->string('parent_menu_code')->nullable(); $table->string('content_type')->default('dynamic'); }); Schema::create('roles_menues', function (Blueprint $table): void { $table->string('rol_codigo'); $table->string('menu_codigo'); }); Schema::create('tenants_menues', function (Blueprint $table): void { $table->string('tenant_code'); $table->string('menu_code'); $table->json('static_content')->nullable(); $table->timestamps(); }); foreach ([ '2026_06_18_130006_create_personal_access_tokens_table.php', '2026_07_28_000000_create_roles_and_permissions_tables.php', '2026_07_29_000000_add_login_security_fields_to_users_table.php', '2026_07_29_000100_create_login_attempts_table.php', ] as $file) { (require database_path('migrations/'.$file))->up(); } DB::table('roles')->insert(['codigo' => 'adminapp', 'nombre' => 'AdminApp']); DB::table('tenants')->insert([ ['codigo' => 'onticket', 'nombre' => 'OnTicket', 'dominio' => 'onticket.test'], ['codigo' => 'other', 'nombre' => 'Other', 'dominio' => 'other.test'], ]); DB::table('events')->insert([ ['id' => 1, 'tenant_code' => 'onticket', 'title' => 'Evento A'], ['id' => 2, 'tenant_code' => 'onticket', 'title' => 'Evento B'], ['id' => 3, 'tenant_code' => 'other', 'title' => 'Evento ajeno'], ]); // An existing administrator must remain general after applying the new migration. DB::table('users')->insert([ 'id' => 1, 'nombre_apellido' => 'Admin', 'email' => 'admin@example.test', 'password' => Hash::make('secret123'), 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket', ]); $this->scopeMigration()->up(); $this->user = User::query()->findOrFail(1); DB::table('menues')->insert([ ['code' => 'main.adminapp', 'label' => 'Administración', 'route' => '/', 'parent_menu_code' => null], ['code' => 'adminapp.ventas', 'label' => 'Ventas', 'route' => '/admin/ventas', 'parent_menu_code' => 'main.adminapp'], ]); foreach (['main.adminapp', 'adminapp.ventas'] as $code) { DB::table('roles_menues')->insert(['rol_codigo' => 'adminapp', 'menu_codigo' => $code]); DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => $code]); } } private function scopeMigration(): Migration { return require database_path('migrations/2026_09_30_000000_add_admin_scope_to_users.php'); } private function login(array $extra = []): TestResponse { return $this->postJson('/api/v1/adminapp/login', [ 'email' => ' ADMIN@EXAMPLE.TEST ', 'password' => 'secret123', ...$extra, ]); } public function test_existing_admin_keeps_general_access_after_migration(): void { $this->assertTrue($this->user->isTenantAdministrator()); $token = $this->login()->assertOk()->assertJsonPath('user.admin_scope', 'tenant') ->assertJsonPath('user.event_id', null)->json('token'); $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk() ->assertJsonPath('data.event', null)->assertJsonCount(1, 'data.tenant.menues.0.submenues'); } public function test_event_admin_logs_in_and_restores_only_its_assigned_context(): void { $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $this->assertSame(1, $this->user->event->id); // Scope cannot be chosen by the caller during login. $token = $this->login(['admin_scope' => 'tenant', 'event_id' => 2])->assertOk() ->assertJsonPath('user.admin_scope', 'event')->assertJsonPath('user.event_id', 1)->json('token'); $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk() ->assertJsonPath('data.event.id', 1)->assertJsonPath('data.event.title', 'Evento A') ->assertJsonPath('data.tenant.codigo', 'onticket') ->assertJsonCount(1, 'data.tenant.menues.0.submenues'); $this->assertSame(['adminapp'], $this->user->tokens()->sole()->abilities); } public function test_event_admins_of_the_same_tenant_receive_the_same_assigned_menus(): void { DB::table('menues')->insert([ 'code' => 'onticket.adminapp.event', 'label' => 'Eventos', 'route' => '/admin/event', 'parent_menu_code' => 'main.adminapp', ]); DB::table('roles_menues')->insert([ 'rol_codigo' => 'adminapp', 'menu_codigo' => 'onticket.adminapp.event', ]); DB::table('tenants_menues')->insert([ 'tenant_code' => 'onticket', 'menu_code' => 'onticket.adminapp.event', ]); $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $token = $this->login()->assertOk()->json('token'); $firstMenus = $this->withToken($token)->getJson('/api/v1/adminapp/me') ->assertOk()->json('data.tenant.menues'); $this->user->update(['event_id' => 2]); $secondMenus = $this->withToken($token)->getJson('/api/v1/adminapp/me') ->assertOk()->json('data.tenant.menues'); $this->assertSame($firstMenus, $secondMenus); $this->assertSame( ['adminapp.ventas', 'onticket.adminapp.event'], collect($firstMenus[0]['submenues'])->pluck('code')->sort()->values()->all(), ); } public function test_invalid_scopes_never_issue_tokens_or_increment_password_failure_counts(): void { foreach ([ ['admin_scope' => 'event', 'event_id' => null], ['admin_scope' => 'event', 'event_id' => 3], ['admin_scope' => 'tenant', 'event_id' => 1], ['admin_scope' => 'unknown', 'event_id' => null], ['admin_scope' => 'event', 'event_id' => 1, 'tenant_codigo' => null], ] as $attributes) { $this->user->update($attributes); $this->login()->assertUnprocessable()->assertJsonValidationErrors('email'); } $this->assertDatabaseCount('personal_access_tokens', 0); $this->assertSame(0, $this->user->refresh()->failed_login_attempts); } public function test_event_admin_cannot_access_tenant_operations_but_can_logout(): void { $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $token = $this->login()->assertOk()->json('token'); foreach (['tenant/tickets', 'tenant/administrators', 'tenant/website-extras', 'forms/tickets-filter'] as $path) { $this->withToken($token)->getJson('/api/v1/adminapp/'.$path)->assertForbidden(); } $this->withToken($token)->postJson('/api/v1/adminapp/tenant/administrators', [])->assertForbidden(); $this->withToken($token)->postJson('/api/logout')->assertOk(); $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_deleting_the_event_invalidates_an_existing_token_without_promoting_the_user(): void { $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $token = $this->login()->assertOk()->json('token'); DB::table('events')->where('id', 1)->delete(); $this->assertNull($this->user->refresh()->event_id); $this->assertSame('event', $this->user->admin_scope); $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden(); $this->withToken($token)->getJson('/api/v1/adminapp/tenant/administrators')->assertForbidden(); } public function test_event_reassignment_and_tenant_changes_apply_to_existing_tokens(): void { $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $token = $this->login()->assertOk()->json('token'); $this->user->update(['event_id' => 2]); $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertOk()->assertJsonPath('data.event.id', 2); DB::table('events')->where('id', 2)->update(['tenant_code' => 'other']); $this->withToken($token)->getJson('/api/v1/adminapp/me')->assertForbidden(); } public function test_wrong_password_still_counts_as_a_failed_attempt(): void { $this->user->update(['admin_scope' => 'event', 'event_id' => 1]); $this->login(['password' => 'wrong'])->assertUnprocessable()->assertJsonValidationErrors('email'); $this->assertSame(1, $this->user->refresh()->failed_login_attempts); $this->assertDatabaseCount('personal_access_tokens', 0); } public function test_scope_migration_can_be_rolled_back_without_removing_users(): void { $this->scopeMigration()->down(); $this->assertFalse(Schema::hasColumn('users', 'admin_scope')); $this->assertFalse(Schema::hasColumn('users', 'event_id')); $this->assertDatabaseHas('users', ['id' => 1, 'email' => 'admin@example.test']); } }