star-cloud/tests/Feature/Admin/AccountHierarchyTest.php
sky121113 201663741d [FEAT] 帳號與角色多層級隔離(主/子帳號樹狀 + 角色權限/機台授權子集把關)
1. 主帳號不變量:storeAccount 的 is_admin 改補位(每家公司恰一主帳號)、destroyAccount 禁刪最後主帳號、roles 新增 is_company_admin 旗標保護公司管理員角色不被刪除
2. 帳號樹狀層級:users 新增 parent_id/level(主帳號 level 0、子帳號最多兩層),新增 visibleTo/canManageAccount,帳號清單與管理端點(accounts/update/destroy/toggle、機台授權選帳號、補貨指派)一律套層級可見範圍與越權防護
3. 角色指派子集:storeAccount/updateAccount 指派角色與帳號 Modal 下拉一律限制為「權限為操作者子集」,杜絕權限提升
4. 機台授權子集:MachinePermissionController 僅允許在操作者本身可授權機台範圍內增刪,保留範圍外既有授權,並修正 machine_access 全域 scope 對讀寫的干擾
5. 角色層級隔離:roles 新增 created_by,子帳號只看/只管/只能指派自己建立的角色(主帳號看全公司),edit/update/destroy 加 canBeManagedBy 守衛
6. 補強:MachineSetting 機台權限人員清單補租戶隔離、修正角色下拉 N+1、三語系新增提示字串
7. 測試:新增 AccountHierarchyTest/AccountSubsetGuardTest/RoleHierarchyTest,Admin 套件 55 測試 0 回歸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:03:43 +08:00

158 lines
7.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 帳號樹狀層級隔離(第二+三批)行為測試:
* - 主帳號唯一性(第一個帳號自動成為主帳號)
* - parent_id / level 歸屬
* - 子帳號深度上限level 2 不可再建)
* - visibleTo 可見範圍隔離(只看自己直建的)
* - canManageAccount 越權防護(不能管旁線)
*/
class AccountHierarchyTest extends TestCase
{
use RefreshDatabase;
protected User $sysAdmin;
protected Company $company;
protected Role $tenantRole;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$superAdmin = Role::create(['name' => 'super-admin', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web']);
$superAdmin->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
Role::create(['name' => '客戶管理員角色模板', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web'])
->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
$this->sysAdmin = User::factory()->create(['company_id' => null]);
$this->sysAdmin->assignRole($superAdmin);
// 供租戶帳號使用、帶有子帳號管理權限的公司層級角色
$this->tenantRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$this->tenantRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
}
/** 建立一個租戶帳號(直接寫入層級欄位)並賦予可建子帳號的角色 */
private function makeTenantUser(array $attrs): User
{
$user = User::factory()->create(array_merge(['company_id' => $this->company->id], $attrs));
$user->assignRole($this->tenantRole);
return $user;
}
public function test_first_company_account_becomes_main(): void
{
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'First', 'username' => 'first', 'email' => 'first@test.co', 'password' => 'password123',
'role' => '客戶管理員角色模板', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'first')->first();
$this->assertTrue($u->is_admin);
$this->assertSame(0, $u->level);
$this->assertNull($u->parent_id);
}
public function test_second_company_account_is_not_main_and_is_child(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'Second', 'username' => 'second', 'email' => 'second@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'second')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
}
public function test_tenant_admin_creates_sub_account_sets_parent_and_level(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'Sub', 'username' => 'sub', 'email' => 'sub@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$u = User::where('username', 'sub')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
$this->assertSame($this->company->id, $u->company_id);
}
public function test_level_two_sub_account_cannot_create_further(): void
{
$deep = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => null]);
$this->actingAs($deep)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'TooDeep', 'username' => 'toodeep', 'email' => 'toodeep@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'toodeep']);
}
public function test_visible_to_isolates_siblings_and_self(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$c = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => $a->id]);
// 子帳號 A 只看得到自己直建的 C看不到自己、旁線 B、主帳號
$visibleToA = User::visibleTo($a)->pluck('id')->all();
$this->assertSame([$c->id], $visibleToA);
// 主帳號看得到同公司全部
$visibleToMain = User::visibleTo($main)->pluck('id')->sort()->values()->all();
$this->assertEquals(collect([$main->id, $a->id, $b->id, $c->id])->sort()->values()->all(), $visibleToMain);
}
public function test_sub_account_cannot_manage_sibling(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// A 嘗試刪除旁線 B → 被擋
$this->actingAs($a)->delete(route('admin.data-config.sub-accounts.destroy', $b->id))
->assertSessionHas('error');
$this->assertDatabaseHas('users', ['id' => $b->id, 'deleted_at' => null]);
// A 嘗試停用旁線 B → 被擋
$this->actingAs($a)->patch(route('admin.data-config.sub-accounts.status.toggle', $b->id))
->assertSessionHas('error');
}
public function test_main_account_can_manage_company_sub_account(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// 主帳號可停用同公司子帳號
$this->actingAs($main)->patch(route('admin.data-config.sub-accounts.status.toggle', $a->id))
->assertSessionHas('success');
}
}