Files
CFDivePlatform/tests/Feature/AdminEndpointAuthTest.php
a620906209 63b25f96ac test(booking): 補預約核心測試——狀態機/防超賣/Scheduler/聊天授權/Admin 權限邊界
稽核 P2-1:平台核心業務(預約)原本零測試覆蓋。
- BookingLifecycleTest:七狀態機合法/非法轉移、名額帳務、24h 取消截止、授權邊界(17 案例)
- BookingOversellTest:confirm 時 lockForUpdate 防超賣不變式、名額釋放回收(3 案例)
- BookingSchedulerTest:48h 過期與日期完成的時間邊界(6 案例)
- BookingChatAuthTest:HTTP 與 presence channel 雙防線(8 案例)
- AdminEndpointAuthTest:非 admin 一律 403、Admin 不可繞過狀態機(5 案例)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:46:46 +08:00

123 lines
4.0 KiB
PHP

<?php
namespace Tests\Feature;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Admin 端點權限邊界(admin-* 規格群)。
*
* /api/admin/* 可停權用戶、刪除課程與評價、讀取全平台個資,
* 任何非 admin 角色(含已登入的 member/provider)都必須被 EnsureAdmin
* middleware 擋在 403,未認證請求擋在 401。
*/
class AdminEndpointAuthTest extends TestCase
{
use RefreshDatabase;
private const ADMIN_GET_ENDPOINTS = [
'/api/admin/stats',
'/api/admin/members',
'/api/admin/providers',
'/api/admin/offers',
'/api/admin/bookings',
'/api/admin/reviews',
];
public function test_member_token_is_rejected_on_all_admin_endpoints(): void
{
$member = User::factory()->create(['role' => 'member']);
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
$this->actingAs($member)->getJson($endpoint)->assertStatus(403);
}
}
public function test_provider_token_is_rejected_on_all_admin_endpoints(): void
{
$provider = User::factory()->create(['role' => 'provider']);
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
$this->actingAs($provider)->getJson($endpoint)->assertStatus(403);
}
}
public function test_unauthenticated_request_is_rejected_on_all_admin_endpoints(): void
{
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
$this->getJson($endpoint)->assertStatus(401);
}
}
public function test_admin_token_can_access_admin_endpoints(): void
{
$admin = User::factory()->create(['role' => 'admin']);
foreach (self::ADMIN_GET_ENDPOINTS as $endpoint) {
$this->actingAs($admin)->getJson($endpoint)->assertOk();
}
}
public function test_admin_complete_follows_booking_state_machine(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$provider = User::factory()->create(['role' => 'provider']);
$member = User::factory()->create(['role' => 'member']);
$offer = DivingOffer::create([
'provider_id' => $provider->id,
'title' => 'Admin Course',
'location' => 'Test',
'spot' => 'Test Spot',
'price' => 1000,
'region' => '南部',
'rating' => 0,
'reviews' => 0,
]);
$schedule = CourseSchedule::create([
'diving_offer_id' => $offer->id,
'provider_id' => $provider->id,
'scheduled_date' => now()->subDay()->toDateString(),
'start_time' => '09:00',
'max_participants' => 4,
'current_participants' => 1,
'status' => ScheduleStatus::Open,
]);
$confirmed = Booking::create([
'schedule_id' => $schedule->id,
'member_id' => $member->id,
'participants' => 1,
'total_price' => 1000,
'status' => BookingStatus::Confirmed,
]);
$this->actingAs($admin)
->putJson("/api/admin/bookings/{$confirmed->id}/complete")
->assertOk();
$this->assertSame(BookingStatus::Completed, $confirmed->fresh()->status);
// Admin 也不能繞過狀態機:rejected 是終態
$rejected = Booking::create([
'schedule_id' => $schedule->id,
'member_id' => User::factory()->create(['role' => 'member'])->id,
'participants' => 1,
'total_price' => 1000,
'status' => BookingStatus::Rejected,
]);
$this->actingAs($admin)
->putJson("/api/admin/bookings/{$rejected->id}/complete")
->assertStatus(422);
$this->assertSame(BookingStatus::Rejected, $rejected->fresh()->status);
}
}