[FEAT] 新增分析管理與子項目的角色權限配置
1. 在 RoleSeeder 中註冊分析管理的 4 個子權限(零錢庫存、機台報表、商品報表、問卷分析),並僅指派「商品報表」權限給租戶管理員模板。
2. 在 PermissionController 與視圖 (role-list, role-card) 的 activeModules 白名單中加入 menu.analysis,使數據分析大項能正常被撈取與顯示。
3. 修改 sidebar-menu.blade.php,用 @can 防護數據分析子選單;修改 web.php 為 4 個分析路由掛載 middleware('can:...') 路由防護。
4. 補全 zh_TW.json、en.json、ja.json 翻譯檔案中關於 4 個新分析權限的金鑰與翻譯文字。
5. 修復 2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php 歷史 migration 以相容 SQLite 測試。
6. 新增 AnalysisPermissionTest.php 測試案例並於本地測試通過,保證多租戶與 RBAC 架構之權限隔離與防越權機制 100% 正確運作。
This commit is contained in:
parent
ecb3750abe
commit
916d0891f1
@ -577,6 +577,7 @@ class PermissionController extends Controller
|
|||||||
'menu.machines',
|
'menu.machines',
|
||||||
'menu.warehouses',
|
'menu.warehouses',
|
||||||
'menu.sales',
|
'menu.sales',
|
||||||
|
'menu.analysis',
|
||||||
'menu.data-config',
|
'menu.data-config',
|
||||||
'menu.remote',
|
'menu.remote',
|
||||||
'menu.special-permission',
|
'menu.special-permission',
|
||||||
|
|||||||
@ -12,10 +12,9 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('machine_stock_movements', function (Blueprint $table) {
|
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||||
// 在 MySQL 中,更新 ENUM 建議使用 DB::statement 以確保相容性
|
|
||||||
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback', 'sale', 'decommission') NOT NULL");
|
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback', 'sale', 'decommission') NOT NULL");
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,8 +22,8 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('machine_stock_movements', function (Blueprint $table) {
|
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
|
||||||
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback') NOT NULL");
|
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback') NOT NULL");
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -40,6 +40,10 @@ class RoleSeeder extends Seeder
|
|||||||
'menu.sales.pass-codes',
|
'menu.sales.pass-codes',
|
||||||
'menu.sales.store-gifts',
|
'menu.sales.store-gifts',
|
||||||
'menu.analysis',
|
'menu.analysis',
|
||||||
|
'menu.analysis.change-stock',
|
||||||
|
'menu.analysis.machine-reports',
|
||||||
|
'menu.analysis.product-reports',
|
||||||
|
'menu.analysis.survey-analysis',
|
||||||
'menu.audit',
|
'menu.audit',
|
||||||
'menu.data-config',
|
'menu.data-config',
|
||||||
'menu.data-config.products',
|
'menu.data-config.products',
|
||||||
@ -102,6 +106,7 @@ class RoleSeeder extends Seeder
|
|||||||
'menu.sales.pass-codes',
|
'menu.sales.pass-codes',
|
||||||
'menu.sales.store-gifts',
|
'menu.sales.store-gifts',
|
||||||
'menu.analysis',
|
'menu.analysis',
|
||||||
|
'menu.analysis.product-reports',
|
||||||
'menu.audit',
|
'menu.audit',
|
||||||
'menu.data-config',
|
'menu.data-config',
|
||||||
'menu.data-config.products',
|
'menu.data-config.products',
|
||||||
|
|||||||
@ -1886,6 +1886,10 @@
|
|||||||
"machines": "Machine Management",
|
"machines": "Machine Management",
|
||||||
"members": "Member Management",
|
"members": "Member Management",
|
||||||
"menu.analysis": "Data Analysis",
|
"menu.analysis": "Data Analysis",
|
||||||
|
"menu.analysis.change-stock": "Change Stock",
|
||||||
|
"menu.analysis.machine-reports": "Machine Reports",
|
||||||
|
"menu.analysis.product-reports": "Product Reports",
|
||||||
|
"menu.analysis.survey-analysis": "Survey Analysis",
|
||||||
"menu.app": "APP Maintenance",
|
"menu.app": "APP Maintenance",
|
||||||
"menu.audit": "Audit Management",
|
"menu.audit": "Audit Management",
|
||||||
"menu.basic": "Basic Settings",
|
"menu.basic": "Basic Settings",
|
||||||
|
|||||||
@ -1886,6 +1886,10 @@
|
|||||||
"machines": "機器管理",
|
"machines": "機器管理",
|
||||||
"members": "会員管理",
|
"members": "会員管理",
|
||||||
"menu.analysis": "データ分析",
|
"menu.analysis": "データ分析",
|
||||||
|
"menu.analysis.change-stock": "両替在庫",
|
||||||
|
"menu.analysis.machine-reports": "機台レポート",
|
||||||
|
"menu.analysis.product-reports": "商品レポート",
|
||||||
|
"menu.analysis.survey-analysis": "アンケート分析",
|
||||||
"menu.app": "APP 運用保守",
|
"menu.app": "APP 運用保守",
|
||||||
"menu.audit": "監査管理",
|
"menu.audit": "監査管理",
|
||||||
"menu.basic": "基本設定",
|
"menu.basic": "基本設定",
|
||||||
|
|||||||
@ -1921,6 +1921,10 @@
|
|||||||
"member_price": "會員價",
|
"member_price": "會員價",
|
||||||
"members": "會員管理",
|
"members": "會員管理",
|
||||||
"menu.analysis": "數據分析",
|
"menu.analysis": "數據分析",
|
||||||
|
"menu.analysis.change-stock": "零錢庫存",
|
||||||
|
"menu.analysis.machine-reports": "機台報表",
|
||||||
|
"menu.analysis.product-reports": "商品報表",
|
||||||
|
"menu.analysis.survey-analysis": "問卷分析",
|
||||||
"menu.app": "APP 運維",
|
"menu.app": "APP 運維",
|
||||||
"menu.audit": "審核管理",
|
"menu.audit": "審核管理",
|
||||||
"menu.basic": "基本設定",
|
"menu.basic": "基本設定",
|
||||||
|
|||||||
@ -98,7 +98,7 @@
|
|||||||
<td class="px-6 py-6" width="30%">
|
<td class="px-6 py-6" width="30%">
|
||||||
<div class="flex flex-wrap gap-1 max-w-xs">
|
<div class="flex flex-wrap gap-1 max-w-xs">
|
||||||
@php
|
@php
|
||||||
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
|
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
|
||||||
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
|
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
|
||||||
if ($p->name === 'menu.data-config.sub-account-roles') return false;
|
if ($p->name === 'menu.data-config.sub-account-roles') return false;
|
||||||
if (str_starts_with($p->name, 'menu.')) {
|
if (str_starts_with($p->name, 'menu.')) {
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Permissions') }}</p>
|
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Permissions') }}</p>
|
||||||
@php
|
@php
|
||||||
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
|
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.analysis', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
|
||||||
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
|
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
|
||||||
if ($p->name === 'menu.data-config.sub-account-roles') return false;
|
if ($p->name === 'menu.data-config.sub-account-roles') return false;
|
||||||
if (str_starts_with($p->name, 'menu.')) {
|
if (str_starts_with($p->name, 'menu.')) {
|
||||||
|
|||||||
@ -234,22 +234,33 @@
|
|||||||
</button>
|
</button>
|
||||||
<div x-show="open && !sidebarCollapsed" x-collapse>
|
<div x-show="open && !sidebarCollapsed" x-collapse>
|
||||||
<ul class="luxury-submenu" data-sidebar-sub>
|
<ul class="luxury-submenu" data-sidebar-sub>
|
||||||
|
@can('menu.analysis.change-stock')
|
||||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.change-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.change-stock') }}">
|
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.change-stock') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.change-stock') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="16" cy="16" r="6"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="16" cy="16" r="6"/></svg>
|
||||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Change Stock') }}</span>
|
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Change Stock') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('menu.analysis.machine-reports')
|
||||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.machine-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.machine-reports') }}">
|
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.machine-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.machine-reports') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /><path d="M6 12l3-3 3 3 6-6" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /><path d="M6 12l3-3 3 3 6-6" /></svg>
|
||||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Machine Reports') }}</span>
|
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Machine Reports') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('menu.analysis.product-reports')
|
||||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.product-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.product-reports') }}">
|
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.product-reports') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.product-reports') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" /><line x1="3" y1="6" x2="21" y2="6" /><path d="M16 10a4 4 0 0 1-8 0" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" /><line x1="3" y1="6" x2="21" y2="6" /><path d="M16 10a4 4 0 0 1-8 0" /></svg>
|
||||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Product Reports') }}</span>
|
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Product Reports') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@can('menu.analysis.survey-analysis')
|
||||||
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.survey-analysis') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.survey-analysis') }}">
|
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.analysis.survey-analysis') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.analysis.survey-analysis') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" /><rect x="9" y="3" width="6" height="4" rx="1" ry="1" /><path d="M9 14l2 2 4-4" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" /><rect x="9" y="3" width="6" height="4" rx="1" ry="1" /><path d="M9 14l2 2 4-4" /></svg>
|
||||||
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Survey Analysis') }}</span>
|
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Survey Analysis') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
@endcan
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -146,10 +146,10 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
|
|||||||
|
|
||||||
// 7. 分析管理
|
// 7. 分析管理
|
||||||
Route::prefix('analysis')->name('analysis.')->group(function () {
|
Route::prefix('analysis')->name('analysis.')->group(function () {
|
||||||
Route::get('/change-stock', [App\Http\Controllers\Admin\AnalysisController::class, 'changeStock'])->name('change-stock');
|
Route::get('/change-stock', [App\Http\Controllers\Admin\AnalysisController::class, 'changeStock'])->name('change-stock')->middleware('can:menu.analysis.change-stock');
|
||||||
Route::get('/machine-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'machineReports'])->name('machine-reports');
|
Route::get('/machine-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'machineReports'])->name('machine-reports')->middleware('can:menu.analysis.machine-reports');
|
||||||
Route::get('/product-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'productReports'])->name('product-reports');
|
Route::get('/product-reports', [App\Http\Controllers\Admin\AnalysisController::class, 'productReports'])->name('product-reports')->middleware('can:menu.analysis.product-reports');
|
||||||
Route::get('/survey-analysis', [App\Http\Controllers\Admin\AnalysisController::class, 'surveyAnalysis'])->name('survey-analysis');
|
Route::get('/survey-analysis', [App\Http\Controllers\Admin\AnalysisController::class, 'surveyAnalysis'])->name('survey-analysis')->middleware('can:menu.analysis.survey-analysis');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 8. 稽核管理
|
// 8. 稽核管理
|
||||||
|
|||||||
129
tests/Feature/Admin/AnalysisPermissionTest.php
Normal file
129
tests/Feature/Admin/AnalysisPermissionTest.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use App\Models\System\Company;
|
||||||
|
use App\Models\System\User;
|
||||||
|
use App\Models\System\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AnalysisPermissionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $company;
|
||||||
|
protected $superAdmin;
|
||||||
|
protected $tenantAdmin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// 1. 運行 RoleSeeder 以確保所有權限與角色都被播種
|
||||||
|
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||||
|
|
||||||
|
// 2. 建立測試公司
|
||||||
|
$this->company = Company::create([
|
||||||
|
'name' => 'Test Company',
|
||||||
|
'code' => 'TEST',
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 註冊 SQLite 相容的 DATE_FORMAT 函數
|
||||||
|
if (\Illuminate\Support\Facades\Schema::getConnection()->getDriverName() === 'sqlite') {
|
||||||
|
$pdo = \Illuminate\Support\Facades\Schema::getConnection()->getPdo();
|
||||||
|
$pdo->sqliteCreateFunction('DATE_FORMAT', function ($date, $format) {
|
||||||
|
if (empty($date)) return null;
|
||||||
|
$phpFormat = str_replace(
|
||||||
|
['%Y', '%m', '%d', '%H', '%i', '%s'],
|
||||||
|
['Y', 'm', 'd', 'H', 'i', 's'],
|
||||||
|
$format
|
||||||
|
);
|
||||||
|
return date($phpFormat, strtotime($date));
|
||||||
|
}, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 建立 Super Admin 帳號
|
||||||
|
$this->superAdmin = User::create([
|
||||||
|
'name' => 'Super Admin User',
|
||||||
|
'username' => 'superadmin_test',
|
||||||
|
'email' => 'superadmin@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'company_id' => null,
|
||||||
|
]);
|
||||||
|
$this->superAdmin->assignRole('super-admin');
|
||||||
|
|
||||||
|
// 4. 建立租戶管理員帳號,並克隆其「管理員」角色
|
||||||
|
// 我們的 RoleSeeder 會將 'menu.analysis.product-reports' 賦予 tenantAdmin 角色範本
|
||||||
|
// 租戶的「管理員」將繼承該範本的權限。
|
||||||
|
$this->tenantAdmin = User::create([
|
||||||
|
'name' => 'Tenant Admin User',
|
||||||
|
'username' => 'tenantadmin_test',
|
||||||
|
'email' => 'tenantadmin@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 克隆 RoleSeeder 建立的 tenantAdmin 角色權限至該租戶下命名為「管理員」
|
||||||
|
$tenantAdminTemplate = Role::where('name', '客戶管理員角色模板')->first();
|
||||||
|
$this->assertNotNull($tenantAdminTemplate);
|
||||||
|
|
||||||
|
$tenantRole = Role::create([
|
||||||
|
'name' => '管理員',
|
||||||
|
'company_id' => $this->company->id,
|
||||||
|
'guard_name' => 'web',
|
||||||
|
'is_system' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 同步權限
|
||||||
|
$tenantRole->syncPermissions($tenantAdminTemplate->permissions);
|
||||||
|
$this->tenantAdmin->assignRole($tenantRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試超級管理員可以訪問所有的分析管理子路由
|
||||||
|
*/
|
||||||
|
public function test_super_admin_can_access_all_analysis_routes()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->superAdmin);
|
||||||
|
|
||||||
|
$routes = [
|
||||||
|
'admin.analysis.change-stock',
|
||||||
|
'admin.analysis.machine-reports',
|
||||||
|
'admin.analysis.product-reports',
|
||||||
|
'admin.analysis.survey-analysis',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$response = $this->get(route($route));
|
||||||
|
// 應成功訪問 (由於 Controller 實作可能回傳檢視或 200)
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試租戶管理員只能訪問商品報表,其他 3 個路由會回傳 403 越權錯誤
|
||||||
|
*/
|
||||||
|
public function test_tenant_admin_can_only_access_product_reports()
|
||||||
|
{
|
||||||
|
$this->actingAs($this->tenantAdmin);
|
||||||
|
|
||||||
|
// 1. 可以正常訪問商品報表
|
||||||
|
$response = $this->get(route('admin.analysis.product-reports'));
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
// 2. 其他 3 個路由皆應回傳 403 Forbidden 越權錯誤
|
||||||
|
$forbiddenRoutes = [
|
||||||
|
'admin.analysis.change-stock',
|
||||||
|
'admin.analysis.machine-reports',
|
||||||
|
'admin.analysis.survey-analysis',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($forbiddenRoutes as $route) {
|
||||||
|
$response = $this->get(route($route));
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user