https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무 전수조사에서 확정한 결함군을 닫는다. ## 디버그 라우트 게이트 (F1~F5) 게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에 `File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고, production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은 User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴 구조였다. 게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로 올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다. `withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는 제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치 우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다. ## 확장 자산 CSS 상대 참조 (범위 추가 — 결정) `asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준 디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가 존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서 통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로 제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다. 둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이 정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류 갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 — 갈라지면 한쪽만 고쳐진 채 남는다. 이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가 정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다. ## 회귀 잠금 되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입, `channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들 치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다. 게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환 배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은 목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
237 lines
8.9 KiB
PHP
237 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Broadcasting\Factory;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* broadcasting/auth 채널 인증 토글 가드 테스트 (공개#50).
|
|
*
|
|
* 웹소켓 사용 OFF(broadcasting.default='null') 시 채널 인증이 거부되어,
|
|
* 변경 1(reverb.key 무력화)을 우회한 직접 연결 시도까지 차단되는지 검증한다.
|
|
*/
|
|
class BroadcastingAuthTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 웹소켓 OFF 시 broadcasting/auth 가 403 을 반환하는지 테스트합니다.
|
|
*/
|
|
public function test_broadcasting_auth_returns_403_when_websocket_disabled(): void
|
|
{
|
|
Config::set('broadcasting.default', 'null');
|
|
|
|
$user = User::factory()->create();
|
|
Sanctum::actingAs($user, ['*'], 'sanctum');
|
|
|
|
$response = $this->postJson('/api/broadcasting/auth', [
|
|
'channel_name' => 'private-test',
|
|
'socket_id' => '123.456',
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* 비인증 요청은 401 을 반환하는지 테스트합니다 (회귀 방지).
|
|
*/
|
|
public function test_broadcasting_auth_returns_401_without_authentication(): void
|
|
{
|
|
Config::set('broadcasting.default', 'reverb');
|
|
|
|
$response = $this->postJson('/api/broadcasting/auth', [
|
|
'channel_name' => 'private-test',
|
|
'socket_id' => '123.456',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 웹소켓 ON + 인증 시 토글 가드를 통과해 Broadcast::auth 로 진입하는지 테스트합니다.
|
|
*
|
|
* 채널 인증 콜백이 없는 임의 채널이므로 403(인증 통과 후 채널 권한 거부)을 받는다.
|
|
* 핵심은 토글 가드(broadcasting.default='null')에 의한 사전 403 이 아님을 구분하는 것 —
|
|
* Broadcast::auth 단계까지 도달함을 검증한다.
|
|
*/
|
|
public function test_broadcasting_auth_passes_toggle_guard_when_enabled(): void
|
|
{
|
|
Config::set('broadcasting.default', 'reverb');
|
|
|
|
$user = User::factory()->create();
|
|
Sanctum::actingAs($user, ['*'], 'sanctum');
|
|
|
|
$response = $this->postJson('/api/broadcasting/auth', [
|
|
'channel_name' => 'private-nonexistent-channel',
|
|
'socket_id' => '123.456',
|
|
]);
|
|
|
|
// 토글 가드(403)에서 멈추지 않고 Broadcast::auth 로 진입 → 채널 권한 거부(403/200).
|
|
// 토글 OFF 가 아님을 확인: 401(미인증) 이 아닌 것으로 인증 통과 입증.
|
|
$this->assertNotSame(401, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* Sitemap 진행상황 채널이 등록되어 있고, core.settings.read 권한 보유 시 인증을 허용한다.
|
|
*
|
|
* broadcasting/auth HTTP 엔드포인트는 이 하네스에서 서명 응답을 낼 수 없어(권한 유무와 무관하게 403)
|
|
* 인증 여부를 구분하지 못하므로, 등록된 채널 콜백을 직접 호출해 인가 로직을 검증한다.
|
|
*/
|
|
public function test_sitemap_channel_authorizes_user_with_settings_read(): void
|
|
{
|
|
$callback = $this->channelCallback('core.admin.seo.sitemap');
|
|
|
|
$user = $this->userWithPermission('core.settings.read');
|
|
|
|
$this->assertTrue((bool) $callback($user));
|
|
}
|
|
|
|
/**
|
|
* Sitemap 진행상황 채널: core.settings.read 권한 미보유 시 거부.
|
|
*/
|
|
public function test_sitemap_channel_rejects_user_without_permission(): void
|
|
{
|
|
$callback = $this->channelCallback('core.admin.seo.sitemap');
|
|
|
|
$user = User::factory()->create();
|
|
|
|
$this->assertFalse((bool) $callback($user));
|
|
}
|
|
|
|
/**
|
|
* `broadcasting/auth` 로 끝나는 라우트는 게이트된 `api/broadcasting/auth` 하나뿐이어야 한다.
|
|
*
|
|
* 배경 (이슈 #128 F4):
|
|
* `bootstrap/app.php` 의 `withRouting(channels: ...)` 인자는 ApplicationBuilder 가
|
|
* `withBroadcasting()` 을 자동 호출하게 만들고, 그 결과 `booted` 에서 `Broadcast::routes()`
|
|
* 가 **게이트 없는** `GET|POST /broadcasting/auth` 를 등록했다. 프론트는 이 경로를 쓰지
|
|
* 않으므로(`WebSocketManager` 는 `/api/broadcasting/auth` 만 호출) 死라우트인 동시에,
|
|
* 웹소켓 킬스위치(`broadcasting.default === 'null'`, 공개#50)를 통째로 우회하는 경로였다.
|
|
* production 에서 미인증 POST 가 200 을 받았다.
|
|
*
|
|
* 채널 정의(`routes/channels.php`)는 `App\Providers\BroadcastServiceProvider` 가
|
|
* `require` 로 계속 로드하므로 인가 콜백은 그대로 살아 있다 — 아래 채널 테스트가 그 증거다.
|
|
*/
|
|
public function test_only_the_gated_api_broadcasting_auth_route_is_registered(): void
|
|
{
|
|
$found = [];
|
|
|
|
foreach (RouteFacade::getRoutes() as $route) {
|
|
$uri = $route->uri();
|
|
|
|
if ($uri === 'broadcasting/auth' || str_ends_with($uri, '/broadcasting/auth')) {
|
|
$found[$uri] = $route->gatherMiddleware();
|
|
}
|
|
}
|
|
|
|
// 모집단 가드 — 라우트가 하나도 안 잡히면 이 단언은 공허참이 된다.
|
|
$this->assertArrayHasKey(
|
|
'api/broadcasting/auth',
|
|
$found,
|
|
'게이트된 api/broadcasting/auth 라우트가 등록되어 있지 않습니다.'
|
|
);
|
|
|
|
$this->assertSame(
|
|
['api/broadcasting/auth'],
|
|
array_keys($found),
|
|
'게이트 없는 broadcasting/auth 라우트가 등록되어 있습니다 (킬스위치 우회로): '
|
|
.implode(', ', array_keys($found))
|
|
);
|
|
|
|
$this->assertContains(
|
|
'auth:sanctum',
|
|
$found['api/broadcasting/auth'],
|
|
'api/broadcasting/auth 에 auth:sanctum 이 붙어 있지 않습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 채널 정의(`routes/channels.php`)가 여전히 로드되어야 한다.
|
|
*
|
|
* `withRouting(channels: ...)` 인자를 제거했으므로 채널 등록은 전적으로
|
|
* `BroadcastServiceProvider::boot()` 의 `require` 에 달려 있다. 이 로드가 사라지면
|
|
* 예외 없이 **모든 private 채널 구독이 403** 이 된다 — 콜백이 없는 채널은 거부되기 때문.
|
|
*/
|
|
public function test_channel_definitions_are_loaded_by_broadcast_service_provider(): void
|
|
{
|
|
foreach (['App.Models.User.{id}', 'core.admin.dashboard', 'core.admin.seo.sitemap'] as $channel) {
|
|
$this->assertInstanceOf(
|
|
\Closure::class,
|
|
$this->channelCallback($channel),
|
|
"채널 정의가 로드되지 않았습니다: {$channel}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 등록된 브로드캐스트 채널의 인가 콜백을 반환합니다.
|
|
*
|
|
* @param string $channel 채널명 (private- 접두 없이)
|
|
* @return \Closure 인가 콜백 (첫 인자 = 사용자)
|
|
*/
|
|
private function channelCallback(string $channel): \Closure
|
|
{
|
|
$broadcaster = app(Factory::class)->connection();
|
|
|
|
$ref = new \ReflectionClass($broadcaster);
|
|
while ($ref !== false && ! $ref->hasProperty('channels')) {
|
|
$ref = $ref->getParentClass();
|
|
}
|
|
$this->assertNotFalse($ref, 'broadcaster 에 channels 프로퍼티가 없음');
|
|
|
|
$prop = $ref->getProperty('channels');
|
|
$prop->setAccessible(true);
|
|
$channels = $prop->getValue($broadcaster);
|
|
|
|
$this->assertArrayHasKey($channel, $channels, "채널이 등록되지 않음: {$channel}");
|
|
|
|
return $channels[$channel];
|
|
}
|
|
|
|
/**
|
|
* 지정한 관리자 권한을 가진 사용자를 생성합니다.
|
|
*
|
|
* @param string $permission 권한 식별자
|
|
* @return User 권한이 부여된 사용자
|
|
*/
|
|
private function userWithPermission(string $permission): User
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$perm = Permission::firstOrCreate(
|
|
['identifier' => $permission],
|
|
[
|
|
'name' => json_encode(['ko' => $permission, 'en' => $permission]),
|
|
'description' => json_encode(['ko' => $permission, 'en' => $permission]),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
|
|
$role = Role::create([
|
|
'identifier' => 'sitemap_channel_test_'.uniqid(),
|
|
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
|
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
|
'is_active' => true,
|
|
]);
|
|
$role->permissions()->sync([$perm->id]);
|
|
|
|
$user->roles()->attach($role->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
}
|