「每支測試都要先登入一個有權限的使用者,是不是寫一個共用函式,直接把使用者塞進去就好?」
聽起來理所當然,但這句話裡藏著一個容易被忽略的陷阱:如果那個「共用函式」為了圖方便,順手把測試使用者的權限開到最大,你的測試套件從那一刻起,驗證的就不是「一般管理員看得到什麼」,而是「上帝視角看得到什麼」。這兩者的差距,通常要等到某天正式環境出現一個「管理員怎麼看不到這個東西」的回報時,才會突然變得很重要。
今天要拆的是這個系統裡兩支測試輔助函式,它們解決的表面問題是「省重複程式碼」,但背後真正的設計判斷是「測試環境要不要跟著遵守最小權限原則」。
Gate::before() 動態賦權,只放行一組明確的能力清單這個系統的測試輔助檔案裡有兩支很常被呼叫的函式:一支模擬一般管理員登入,一支模擬最高權限使用者登入。乍看之下,兩支函式做的事情差不多——都是建立一個使用者、指派角色、回傳一個已登入的測試情境,方便後面的測試直接接著寫斷言。
但實際打開實作,會發現兩支函式的權限設計完全不同層級:
最高權限的那一支,實作相對單純:建立一個使用者,指派一個名字叫「Super Admin」的角色,結束。因為在這個系統的授權設計裡,「Super Admin」角色本身就代表通行無阻,不需要額外設定任何細節。
一般管理員的那一支,做的事情多了一步:它沒有直接把使用者的角色設成能通過任何檢查,而是用 Laravel 授權機制裡的 Gate::before() 動態掛上一段邏輯——只有當使用者角色是「Admin」、而且這次要檢查的能力落在一組明確列出的白名單裡(例如查看列表、查看單筆、建立、更新、刪除、排序、複製、稽核紀錄),才自動放行;不在白名單裡的能力,一律回到正常的授權檢查流程,不會被這個測試輔助函式偷偷開後門。
如果你只是想讓測試「能跑過去」,最偷懶的做法確實是兩支函式都建一個萬能身份,反正權限夠大,什麼操作都通得過,測試不會因為權限不夠而莫名其妙失敗。
但這樣做的代價是:你會失去「一般管理員在這個系統裡實際能做什麼、不能做什麼」這件事的測試覆蓋。如果之後有人不小心把某個功能的權限檢查寫錯(例如應該只有 Super Admin 能刪除某筆資料,卻寫成一般 Admin 也能刪),一支只用萬能身份跑的測試套件完全抓不到這個錯誤——因為測試從頭到尾都是用最大權限在操作,任何權限檢查在它面前都是通的。
這個系統的設計選擇是:測試輔助函式本身也要遵守最小權限原則——asAdmin() 給的身份,權限範圍要盡量貼近「真實情境裡一個 Admin 角色實際擁有的能力」,而不是為了讓測試好寫,就把身份的權限無限放大。
function asAdmin(): TestCase
{
$user = User::factory()->createOne();
$user->assignRole('Super Admin'); // 圖方便,直接給最大權限
return test()->actingAs($user);
}
看起來能讓所有測試順利跑過,但代價是:任何原本應該只限 Super Admin 才能做的操作,用這個「假裝是 Admin」的身份一樣暢行無阻,測試完全驗證不出權限邊界有沒有寫對。
function asAdmin(): TestCase
{
$standardAbilities = [
'viewAny', 'view', 'create', 'update', 'delete',
'deleteAny', 'forceDelete', 'forceDeleteAny',
'reorder', 'replicate', 'audit',
];
Gate::before(function (?User $user, string $ability) use ($standardAbilities) {
return $user?->hasRole('Admin')
&& in_array($ability, $standardAbilities)
? true
: null; // 不在白名單內,交還給正常授權邏輯判斷
});
return test()->actingAs(User::factory()->admin()->createOne());
}
Gate::before() 回傳 null(而不是 false)是這裡的關鍵細節——回傳 null 代表「這個 hook 不表態,交給後面正常的 Policy/Gate 邏輯繼續判斷」,只有明確在白名單內才直接放行。這讓 asAdmin() 建出來的測試身份,真正只在「一般 Admin 該有的能力」範圍內暢通,白名單以外的能力,還是得靠真正的授權邏輯決定通不通過。
除了這兩支模擬登入的函式,還有一支更不起眼的輔助函式:確保系統裡至少存在一個使用者。
為什麼需要這個?因為這個系統的授權邏輯裡有一條隱性規則:第一個註冊的使用者自動視為擁有最高權限,這是很多後台系統常見的「初始化引導」設計——系統剛部署、還沒有任何管理員時,總要有一個入口能讓第一個人取得管理權限。
但這條規則放在測試環境裡,會變成一個隱藏的干擾因子:如果測試資料庫目前一個使用者都沒有,你接下來用 User::factory()->createOne() 建立的「應該是一般使用者」的測試資料,可能會因為它剛好是資料庫裡第一筆使用者紀錄,意外被系統判定成最高權限身份,導致測試的行為跟你原本設計的情境不一致。
先確保「至少有一個使用者」存在,等於是先把這條隱性規則的觸發條件排除掉,讓後面建立的測試身份能真正照著你想要的權限層級運作,不會被一個跟這次測試本身無關的初始化邏輯影響結果。
回想你手上維護的測試輔助函式,有沒有一支為了「讓測試好寫」而給了比真實情境更大的權限?如果現在把它的權限收斂到跟真實情境一致,有沒有哪些既有測試會因此開始失敗——那些失敗的測試,會不會正好是原本沒被測到的權限漏洞?
Gate::before() 搭配明確白名單,只放行清楚列出的能力,白名單以外交還正常授權邏輯判斷明天要看這個系統的 Model Factory 怎麼設計語意化的建構方式——superAdmin()、admin() 這種一看名字就知道在建什麼身份的寫法,以及一段被刪掉又留下痕跡的程式碼,暗示著什麼樣的設計取捨。