security(auth): 新增 member/provider 角色 middleware

補上待辦 H1:/api/member/*、/api/provider/* 原本只掛 auth:sanctum,
任何已登入帳號都能跨角色建立課程、預約與評價。

- 新增 EnsureMember、EnsureProvider(結構同 EnsureAdmin),註冊 alias
  member、provider,掛到對應路由群組;角色不符回 403
  { status: false, message: "無權限存取" },未登入仍由 auth:sanctum 回 401
- provider middleware 不檢查審核狀態,未通過審核的教練仍可送審
- 新增 RoleAuthorizationTest:member/provider/admin/未登入跨角色測試,
  並驗證越權請求不產生資料;跨角色共用路由(通知、未讀訊息數)不受影響
- Swagger:member/provider 端點的 403 描述補上角色不符,補齊列表端點 403
- 更新 BookingListTest docblock 的過時描述
- 完成 tasks 1.1–4.3;5.1、5.2 手動驗證尚未執行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 23:57:16 +08:00
parent 9e4331d4f7
commit 16ccb4c18c
9 changed files with 329 additions and 55 deletions
+7 -6
View File
@@ -93,7 +93,8 @@ class MemberApiDoc
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* ) * )
* ) * ),
* @OA\Response(response=403, description="非 member 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function listMemberBookings() public function listMemberBookings()
@@ -119,7 +120,7 @@ class MemberApiDoc
* ) * )
* ), * ),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限存取", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 member 角色或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function getMemberBooking() public function getMemberBooking()
@@ -145,7 +146,7 @@ class MemberApiDoc
* @OA\Property(property="message", type="string", example="預約已取消") * @OA\Property(property="message", type="string", example="預約已取消")
* ) * )
* ), * ),
* @OA\Response(response=403, description="無權限或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 member 角色、非本人預約或狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="預約不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -186,7 +187,7 @@ class MemberApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Review") * @OA\Property(property="data", ref="#/components/schemas/Review")
* ) * )
* ), * ),
* @OA\Response(response=403, description="預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 member 角色、預約未完成或非本人預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -220,7 +221,7 @@ class MemberApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Review") * @OA\Property(property="data", ref="#/components/schemas/Review")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -246,7 +247,7 @@ class MemberApiDoc
* @OA\Property(property="message", type="string", example="評價已刪除") * @OA\Property(property="message", type="string", example="評價已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 member 角色或非本人評價", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="評價不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
+25 -22
View File
@@ -64,7 +64,7 @@ class ProviderApiDoc
* ) * )
* ) * )
* ), * ),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function getProviderVerification() public function getProviderVerification()
@@ -105,7 +105,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function uploadCertification() public function uploadCertification()
@@ -131,7 +131,7 @@ class ProviderApiDoc
* @OA\Property(property="message", type="string", example="證照已刪除") * @OA\Property(property="message", type="string", example="證照已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="無權限或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色、非本人證照或當前狀態不可刪除", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="證照不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -158,7 +158,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="尚未上傳證照或當前狀態不可送審", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function submitVerification() public function submitVerification()
@@ -189,7 +189,8 @@ class ProviderApiDoc
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DivingOffer")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* ) * )
* ) * ),
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function listProviderOffers() public function listProviderOffers()
@@ -228,7 +229,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="無權限", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function createProviderOffer() public function createProviderOffer()
@@ -254,7 +255,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer") * @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -292,7 +293,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/DivingOffer") * @OA\Property(property="data", ref="#/components/schemas/DivingOffer")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -318,7 +319,7 @@ class ProviderApiDoc
* @OA\Property(property="message", type="string", example="課程已刪除") * @OA\Property(property="message", type="string", example="課程已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="課程不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -360,7 +361,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function uploadOfferCover() public function uploadOfferCover()
@@ -385,7 +386,7 @@ class ProviderApiDoc
* @OA\Property(property="message", type="string", example="封面圖片已刪除") * @OA\Property(property="message", type="string", example="封面圖片已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function deleteOfferCover() public function deleteOfferCover()
@@ -427,7 +428,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="圖片驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function uploadOfferImages() public function uploadOfferImages()
@@ -452,7 +453,7 @@ class ProviderApiDoc
* @OA\Property(property="message", type="string", example="圖片已刪除") * @OA\Property(property="message", type="string", example="圖片已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人圖片", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="圖片不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -482,7 +483,8 @@ class ProviderApiDoc
* @OA\Property(property="status", type="boolean", example=true), * @OA\Property(property="status", type="boolean", example=true),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule")) * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CourseSchedule"))
* ) * )
* ) * ),
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function listProviderSchedules() public function listProviderSchedules()
@@ -517,7 +519,7 @@ class ProviderApiDoc
* ) * )
* ), * ),
* @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=422, description="驗證失敗", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=403, description="非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=403, description="非 provider 角色或非本人課程", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function createProviderSchedule() public function createProviderSchedule()
@@ -551,7 +553,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/CourseSchedule") * @OA\Property(property="data", ref="#/components/schemas/CourseSchedule")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -577,7 +579,7 @@ class ProviderApiDoc
* @OA\Property(property="message", type="string", example="時段已刪除") * @OA\Property(property="message", type="string", example="時段已刪除")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人時段", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=404, description="時段不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -609,7 +611,8 @@ class ProviderApiDoc
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Booking")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta")
* ) * )
* ) * ),
* @OA\Response(response=403, description="非 provider 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
public function listProviderBookings() public function listProviderBookings()
@@ -635,7 +638,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Booking") * @OA\Property(property="data", ref="#/components/schemas/Booking")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="狀態不允許確認", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -662,7 +665,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Booking") * @OA\Property(property="data", ref="#/components/schemas/Booking")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="狀態不允許拒絕", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -689,7 +692,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Booking") * @OA\Property(property="data", ref="#/components/schemas/Booking")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="狀態不允許完成", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
@@ -716,7 +719,7 @@ class ProviderApiDoc
* @OA\Property(property="data", ref="#/components/schemas/Booking") * @OA\Property(property="data", ref="#/components/schemas/Booking")
* ) * )
* ), * ),
* @OA\Response(response=403, description="非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")), * @OA\Response(response=403, description="非 provider 角色或非本人課程的預約", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
* @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")) * @OA\Response(response=422, description="狀態不允許取消", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
* ) * )
*/ */
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureMember
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->isMember()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureProvider
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->isProvider()) {
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
}
return $next($request);
}
}
+2
View File
@@ -16,6 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->trustProxies(at: ['*']); $middleware->trustProxies(at: ['*']);
$middleware->alias([ $middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class, 'admin' => \App\Http\Middleware\EnsureAdmin::class,
'member' => \App\Http\Middleware\EnsureMember::class,
'provider' => \App\Http\Middleware\EnsureProvider::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
+21 -21
View File
@@ -4,36 +4,36 @@
## 1. Member 角色 middleware ## 1. Member 角色 middleware
- [ ] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過 - [x] 1.1 [後端] 新增 `app/Http/Middleware/EnsureMember.php`(結構同 `EnsureAdmin`,改用 `isMember()`),並在 `bootstrap/app.php` 的 `$middleware->alias([...])` 加上 `'member' => \App\Http\Middleware\EnsureMember::class`;驗證:1.3 起的測試通過
- [ ] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響 - [x] 1.2 [後端] `routes/api.php` 的 member 群組改為 `Route::middleware(['auth:sanctum', 'member'])->prefix('member')`;驗證:`php artisan route:list --path=api/member` 顯示 `member` middleware,且 `register`、`login` 不受影響
- [ ] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」) - [x] 1.3 [整合測試] `test_member_can_access_member_routes`:member 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 200(對應 Scenario「member 可存取 member 路由」)
- [ ] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」) - [x] 1.4 [整合測試] `test_provider_is_rejected_on_member_routes`:provider 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`、`POST /api/member/reviews`,皆回 403,並斷言 body 為 `{ status: false, message: "無權限存取" }`;`POST /api/member/reviews` 另斷言 `reviews` 筆數不變(對應 Scenario「provider 呼叫 member 路由被拒絕」)
- [ ] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」) - [x] 1.5 [整合測試] `test_admin_is_rejected_on_member_routes`:admin 呼叫 `GET /api/member/profile`、`GET /api/member/bookings`、`POST /api/member/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 member 路由被拒絕」)
- [ ] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」) - [x] 1.6 [整合測試] `test_non_member_booking_attempt_creates_no_booking`:建立可預約時段,provider 帶合法 `schedule_id` 呼叫 `POST /api/member/bookings`,回 403,`bookings` 筆數與 `current_participants` 不變(對應 Scenario「非 member 建立預約不會產生資料」)
- [ ] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」) - [x] 1.7 [整合測試] `test_unauthenticated_request_is_rejected_on_member_routes`:不帶 token 呼叫 `GET /api/member/profile`、`GET /api/member/bookings` 回 401(對應 Scenario「未登入呼叫 member 路由」)
## 2. Provider 角色 middleware ## 2. Provider 角色 middleware
- [ ] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過 - [x] 2.1 [後端] 新增 `app/Http/Middleware/EnsureProvider.php`(結構同 `EnsureAdmin`,改用 `isProvider()`,**不檢查** `verification_status`),並在 `bootstrap/app.php` 加上 `'provider' => \App\Http\Middleware\EnsureProvider::class`;驗證:2.3 起的測試通過
- [ ] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響 - [x] 2.2 [後端] `routes/api.php` 的 provider 群組改為 `Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')`;驗證:`php artisan route:list --path=api/provider` 顯示 `provider` middleware,且 `register`、`login` 不受影響
- [ ] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」) - [x] 2.3 [整合測試] `test_provider_can_access_provider_routes`:provider(含 `providerProfile`)呼叫 `GET /api/provider/profile`、`/offers`、`/schedules`、`/bookings` 回 200(對應 Scenario「provider 可存取 provider 路由」)
- [ ] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」) - [x] 2.4 [整合測試] `test_unapproved_provider_can_access_verification`:`verification_status` 為 `unsubmitted`、`pending`、`rejected` 的 provider 呼叫 `GET /api/provider/verification` 回 200(對應 Scenario「未通過審核的 provider 仍可進入 provider 路由」)
- [ ] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」) - [x] 2.5 [整合測試] `test_member_is_rejected_on_provider_routes`:member 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`POST /api/provider/schedules`、`GET /api/provider/bookings`、`GET /api/provider/verification`,皆回 403 與 `{ status: false, message: "無權限存取" }`;`POST /api/provider/schedules` 另斷言 `course_schedules` 筆數不變(對應 Scenario「member 呼叫 provider 路由被拒絕」)
- [ ] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」) - [x] 2.6 [整合測試] `test_admin_is_rejected_on_provider_routes`:admin 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers`、`POST /api/provider/offers`、`GET /api/provider/bookings`,皆回 403 與相同 body(對應 Scenario「admin 呼叫 provider 路由被拒絕」)
- [ ] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」) - [x] 2.7 [整合測試] `test_non_provider_offer_attempt_creates_no_offer`:member 帶通過驗證的完整課程資料呼叫 `POST /api/provider/offers`,回 403,`diving_offers` 筆數不變(對應 Scenario「非 provider 建立課程不會產生資料」)
- [ ] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」) - [x] 2.8 [整合測試] `test_unauthenticated_request_is_rejected_on_provider_routes`:不帶 token 呼叫 `GET /api/provider/profile`、`GET /api/provider/offers` 回 401(對應 Scenario「未登入呼叫 provider 路由」)
## 3. 共用路由與既有規格的回歸 ## 3. 共用路由與既有規格的回歸
- [ ] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」) - [x] 3.1 [整合測試] `test_member_and_provider_can_read_notifications`:member 與 provider 各自呼叫 `GET /api/notifications` 回 200(對應 Scenario「member 與 provider 皆可讀取通知」)
- [ ] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」) - [x] 3.2 [整合測試] `test_member_and_provider_can_read_unread_message_counts`:member 與 provider 各自呼叫 `GET /api/bookings/messages/unread-counts` 回 200(對應 Scenario「member 與 provider 皆可讀取未讀訊息數」)
- [ ] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述 - [x] 3.3 [整合測試] 執行 `php artisan test --filter=BookingListTest`,確認 `feature-test-coverage`「預約列表端點測試覆蓋」的四個 Scenario(member 隔離、provider 隔離、admin 全部、未認證 401)在新 middleware 下仍全綠(「非 member/非 provider 於 middleware 層 403」由 1.4、1.5、2.5、2.6 覆蓋);同時更新 `tests/Feature/BookingListTest.php` 第 19–24 行 docblock 中「無 role check」「不靠 middleware role 強制」的過時描述
## 4. 全套回歸、Swagger 與格式 ## 4. 全套回歸、Swagger 與格式
- [ ] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報) - [x] 4.1 [整合測試] 執行完整 `php artisan test`(需在容器內,因 `CourseImageTest` 需要 GD);若有既有測試因角色錯誤改回 403,判斷是測試用錯角色(修正測試並在 commit 說明)還是依賴跨角色行為(停下回報)
- [ ] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤 - [x] 4.2 [後端] 核對 `app/Docs/MemberApiDoc.php`、`app/Docs/ProviderApiDoc.php` 每個端點的 403 回應描述涵蓋「角色不符」;缺的補上,並執行 `php artisan l5-swagger:generate` 確認無錯誤
- [ ] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題 - [x] 4.3 [後端] 執行 `./vendor/bin/pint --test app/Http/Middleware/EnsureMember.php app/Http/Middleware/EnsureProvider.php bootstrap/app.php routes/api.php tests/Feature/RoleAuthorizationTest.php`(以及 4.1/4.2 若有修改的檔案),確認無格式問題
## 5. 手動驗證 ## 5. 手動驗證
+2 -2
View File
@@ -37,7 +37,7 @@ Route::middleware([\Illuminate\Session\Middleware\StartSession::class])->group(f
}); });
// 會員專屬 API(需登入) // 會員專屬 API(需登入)
Route::middleware(['auth:sanctum'])->prefix('member')->group(function () { Route::middleware(['auth:sanctum', 'member'])->prefix('member')->group(function () {
// 會員登出 // 會員登出
Route::post('/logout', [AuthController::class, 'logoutMember']); Route::post('/logout', [AuthController::class, 'logoutMember']);
// Token Refresh // Token Refresh
@@ -67,7 +67,7 @@ Route::post('/provider/register', [AuthController::class, 'registerProvider']);
Route::middleware('throttle:10,1')->post('/provider/login', [AuthController::class, 'loginProvider']); Route::middleware('throttle:10,1')->post('/provider/login', [AuthController::class, 'loginProvider']);
// 服務提供者專屬 API(需登入) // 服務提供者專屬 API(需登入)
Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { Route::middleware(['auth:sanctum', 'provider'])->prefix('provider')->group(function () {
// 服務提供者登出 // 服務提供者登出
Route::post('/logout', [AuthController::class, 'logoutProvider']); Route::post('/logout', [AuthController::class, 'logoutProvider']);
// Token Refresh // Token Refresh
+4 -4
View File
@@ -16,12 +16,12 @@ use Tests\TestCase;
* 預約列表端點資料隔離(booking-lifecycle 規格)。 * 預約列表端點資料隔離(booking-lifecycle 規格)。
* *
* 路由對應(routes/api.php 已確認): * 路由對應(routes/api.php 已確認):
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum,無 role check) * GET /api/member/bookings → MemberBookingController::index(auth:sanctum + member middleware)
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum,無 role check) * GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum + provider middleware)
* GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware) * GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware)
* *
* 資料隔離靠 controller query 條件,不靠 middleware role 強制, * 角色 middleware 只擋跨角色呼叫(見 RoleAuthorizationTest);同角色之間的資料隔離
* 因此測試需分別以正確角色 actingAs 驗證隔離邊界。 * 靠 controller query 條件,因此測試需分別以正確角色 actingAs 驗證隔離邊界。
*/ */
class BookingListTest extends TestCase class BookingListTest extends TestCase
{ {
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace Tests\Feature;
use App\Enums\ScheduleStatus;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\ProviderProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Member / Provider 路由群組角色邊界(role-authorization 規格)。
*
* 在 EnsureMember / EnsureProvider 之前,任何已登入帳號都能跨角色呼叫:
* member 可建立課程(provider_id 被設成自己)、provider 可建立預約。
* 這裡驗證角色不符一律在 middleware 層回 403 且不產生資料;
* POST 請求都帶通過驗證的完整資料,確保 403 不是被 422 或資源檢查遮蓋。
*/
class RoleAuthorizationTest extends TestCase
{
use RefreshDatabase;
private const FORBIDDEN_BODY = ['status' => false, 'message' => '無權限存取'];
private function createProvider(string $verificationStatus = 'approved'): User
{
$provider = User::factory()->create(['role' => 'provider']);
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $verificationStatus]);
return $provider;
}
private function createOfferWithOpenSchedule(User $provider): CourseSchedule
{
$offer = DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Dive Course',
'location' => 'Kenting',
'price' => 3000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
return CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $provider->id,
'scheduled_date' => now()->addDays(30)->toDateString(),
'start_time' => '09:00',
'max_participants' => 10,
'current_participants' => 0,
'status' => ScheduleStatus::Open,
]);
}
private function validOfferPayload(): array
{
return [
'title' => 'Cross Role Course',
'location' => 'Green Island',
'price' => 2000,
'region' => '東部',
];
}
private function assertForbidden(User $user, string $method, string $uri, array $payload = []): void
{
$this->actingAs($user)
->json($method, $uri, $payload)
->assertStatus(403)
->assertExactJson(self::FORBIDDEN_BODY);
}
// ── Member 路由 ──────────────────────────────────────────────
public function test_member_can_access_member_routes(): void
{
$member = User::factory()->create(['role' => 'member']);
$this->actingAs($member)->getJson('/api/member/profile')->assertOk()->assertJsonPath('status', true);
$this->actingAs($member)->getJson('/api/member/bookings')->assertOk()->assertJsonPath('status', true);
}
public function test_provider_is_rejected_on_member_routes(): void
{
$provider = $this->createProvider();
$schedule = $this->createOfferWithOpenSchedule($provider);
$this->assertForbidden($provider, 'GET', '/api/member/profile');
$this->assertForbidden($provider, 'GET', '/api/member/bookings');
$this->assertForbidden($provider, 'POST', '/api/member/bookings', [
'schedule_id' => $schedule->id,
'participants' => 1,
]);
$this->assertForbidden($provider, 'POST', '/api/member/reviews', [
'diving_offer_id' => $schedule->diving_offer_id,
'rating' => 5,
'comment' => 'self review',
]);
// provider 為自己的課程寫評價會灌水評分
$this->assertDatabaseCount('reviews', 0);
}
public function test_admin_is_rejected_on_member_routes(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$schedule = $this->createOfferWithOpenSchedule($this->createProvider());
$this->assertForbidden($admin, 'GET', '/api/member/profile');
$this->assertForbidden($admin, 'GET', '/api/member/bookings');
$this->assertForbidden($admin, 'POST', '/api/member/bookings', [
'schedule_id' => $schedule->id,
'participants' => 1,
]);
}
public function test_non_member_booking_attempt_creates_no_booking(): void
{
$provider = $this->createProvider();
$schedule = $this->createOfferWithOpenSchedule($provider);
$this->assertForbidden($provider, 'POST', '/api/member/bookings', [
'schedule_id' => $schedule->id,
'participants' => 2,
]);
// 越權預約若寫入會佔用真實學員的名額
$this->assertDatabaseCount('bookings', 0);
$this->assertSame(0, $schedule->fresh()->current_participants);
}
public function test_unauthenticated_request_is_rejected_on_member_routes(): void
{
$this->getJson('/api/member/profile')->assertStatus(401);
$this->getJson('/api/member/bookings')->assertStatus(401);
}
// ── Provider 路由 ────────────────────────────────────────────
public function test_provider_can_access_provider_routes(): void
{
$provider = $this->createProvider();
foreach (['/api/provider/profile', '/api/provider/offers', '/api/provider/schedules', '/api/provider/bookings'] as $uri) {
$this->actingAs($provider)->getJson($uri)->assertOk()->assertJsonPath('status', true);
}
}
public function test_unapproved_provider_can_access_verification(): void
{
// 未通過審核的教練必須能進入 provider 路由才能送審或補件
foreach (['unsubmitted', 'pending', 'rejected'] as $status) {
$provider = $this->createProvider($status);
$this->actingAs($provider)
->getJson('/api/provider/verification')
->assertOk()
->assertJsonPath('data.verification_status', $status);
}
}
public function test_member_is_rejected_on_provider_routes(): void
{
$member = User::factory()->create(['role' => 'member']);
$schedule = $this->createOfferWithOpenSchedule($this->createProvider());
$this->assertForbidden($member, 'GET', '/api/provider/profile');
$this->assertForbidden($member, 'GET', '/api/provider/offers');
$this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload());
$this->assertForbidden($member, 'POST', '/api/provider/schedules', [
'diving_offer_id' => $schedule->diving_offer_id,
'scheduled_date' => now()->addDays(10)->toDateString(),
'start_time' => '10:00',
'max_participants' => 5,
]);
$this->assertForbidden($member, 'GET', '/api/provider/bookings');
$this->assertForbidden($member, 'GET', '/api/provider/verification');
$this->assertDatabaseCount('course_schedules', 1);
}
public function test_admin_is_rejected_on_provider_routes(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->assertForbidden($admin, 'GET', '/api/provider/profile');
$this->assertForbidden($admin, 'GET', '/api/provider/offers');
$this->assertForbidden($admin, 'POST', '/api/provider/offers', $this->validOfferPayload());
$this->assertForbidden($admin, 'GET', '/api/provider/bookings');
}
public function test_non_provider_offer_attempt_creates_no_offer(): void
{
$member = User::factory()->create(['role' => 'member']);
$this->assertForbidden($member, 'POST', '/api/provider/offers', $this->validOfferPayload());
// 越權建立的課程 provider_id 會是 member,出現在公開列表
$this->assertDatabaseCount('diving_offers', 0);
}
public function test_unauthenticated_request_is_rejected_on_provider_routes(): void
{
$this->getJson('/api/provider/profile')->assertStatus(401);
$this->getJson('/api/provider/offers')->assertStatus(401);
}
// ── 跨角色共用路由 ───────────────────────────────────────────
public function test_member_and_provider_can_read_notifications(): void
{
$member = User::factory()->create(['role' => 'member']);
$provider = $this->createProvider();
$this->actingAs($member)->getJson('/api/notifications')->assertOk();
$this->actingAs($provider)->getJson('/api/notifications')->assertOk();
}
public function test_member_and_provider_can_read_unread_message_counts(): void
{
$member = User::factory()->create(['role' => 'member']);
$provider = $this->createProvider();
$this->actingAs($member)->getJson('/api/bookings/messages/unread-counts')->assertOk();
$this->actingAs($provider)->getJson('/api/bookings/messages/unread-counts')->assertOk();
}
}