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:
@@ -16,12 +16,12 @@ use Tests\TestCase;
|
||||
* 預約列表端點資料隔離(booking-lifecycle 規格)。
|
||||
*
|
||||
* 路由對應(routes/api.php 已確認):
|
||||
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum,無 role check)
|
||||
* GET /api/member/bookings → MemberBookingController::index(auth:sanctum + member middleware)
|
||||
* GET /api/provider/bookings → ProviderBookingController::index(auth:sanctum + provider middleware)
|
||||
* GET /api/admin/bookings → AdminBookingController::index(auth:sanctum + admin middleware)
|
||||
*
|
||||
* 資料隔離靠 controller query 條件,不靠 middleware role 強制,
|
||||
* 因此測試需分別以正確角色 actingAs 驗證隔離邊界。
|
||||
* 角色 middleware 只擋跨角色呼叫(見 RoleAuthorizationTest);同角色之間的資料隔離
|
||||
* 靠 controller query 條件,因此測試需分別以正確角色 actingAs 驗證隔離邊界。
|
||||
*/
|
||||
class BookingListTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user