star-cloud/tests/Feature/Admin/AccountSubsetGuardTest.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

161 lines
7.5 KiB
PHP
Raw Permalink 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\Machine\Machine;
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;
/**
* 第四批:角色指派子集 與 機台授權子集 把關測試。
* - 操作者不可指派「含自身未持有權限」的角色(防權限提升)。
* - 操作者不可授權「自己未被授權」的機台給他人(防機台層級提升)。
* - 操作者不可對非管轄帳號操作機台授權(越權)。
*/
class AccountSubsetGuardTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
// 主帳號角色:僅持有兩個權限(不含 extra.high.perm
$mainRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$mainRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->main = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null,
]);
$this->main->assignRole($mainRole);
}
// ── 角色指派子集 ─────────────────────────────────────────────
public function test_operator_cannot_assign_role_exceeding_own_permissions(): void
{
// 高權限角色:多出操作者沒有的 extra.high.perm
$highRole = Role::create(['name' => 'high-role', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$highRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm']);
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'escalated', 'email' => 'e@test.co', 'password' => 'password123',
'role' => 'high-role', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'escalated']);
}
public function test_operator_can_assign_role_within_own_permissions(): void
{
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'ok_sub', 'email' => 'ok@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$this->assertDatabaseHas('users', ['username' => 'ok_sub']);
}
// ── 機台授權子集 ─────────────────────────────────────────────
public function test_operator_cannot_grant_machine_they_are_not_assigned(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只被授權 m1
// 嘗試把自己沒有的 m2 授權給 target → 422
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id, $m2->id],
])->assertStatus(422);
// 只授權自己有的 m1 → 成功
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id],
])->assertStatus(200);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_operator_cannot_assign_machines_to_non_managed_account(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
// 旁線帳號(非 operator 的下層)
$sibling = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $sibling->id), [
'machine_ids' => [$m1->id],
])->assertStatus(403);
}
public function test_sync_preserves_authorizations_outside_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只可授權 m1
$target->machines()->attach([$m1->id, $m2->id]); // 目標既有 m1、m2m2 在操作者範圍外)
// 操作者送出清空([]):應只移除自己範圍內的 m1保留範圍外的 m2
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [],
])->assertStatus(200);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_get_account_machines_only_lists_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$response = $this->actingAs($operator)->getJson(route('admin.machines.permissions.accounts.get', $target->id));
$response->assertStatus(200);
$ids = collect($response->json('machines'))->pluck('id')->all();
$this->assertContains($m1->id, $ids);
$this->assertNotContains($m2->id, $ids); // 操作者沒被授權的機台不應出現在可授權清單
}
}