feat: Flow Tester 기능 개선 및 안정화

- FlowTesterController: 테스트 실행 로직 개선
  - 에러 핸들링 강화
  - 응답 형식 표준화
- FlowExecutor: API 호출 실행기 개선
  - 다단계 플로우 지원 강화
  - 변수 바인딩 및 검증 개선
- index.blade.php: UI 개선
  - 테스트 결과 표시 개선
  - 사용성 향상
- routes/web.php: 라우트 정리
- composer.lock: 의존성 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 01:35:54 +09:00
parent fd50a6dba0
commit aa1fd76a99
5 changed files with 288 additions and 191 deletions

View File

@@ -342,73 +342,115 @@ public function runDetail(int $runId): View
*/
/**
* 현재 테넌트의 사용자 목록
* API 서버에 직접 로그인하여 토큰 발급
*
* MNG에서 토큰을 발급하면 API 서버에서 인식하지 못하므로,
* API 서버에 직접 로그인하여 토큰을 발급받습니다.
*/
public function users()
public function loginToApi(Request $request)
{
// 현재 선택된 테넌트 ID (세션 기반)
$tenantId = session('selected_tenant_id');
$validated = $request->validate([
'user_id' => 'required|string',
'user_pwd' => 'required|string',
]);
if (! $tenantId) {
// 세션에 없으면 기본 테넌트 사용
$currentTenant = auth()->user()->currentTenant();
$tenantId = $currentTenant?->id;
try {
// API Base URL 결정
$baseUrl = $this->getApiBaseUrl();
// Docker 환경에서는 내부 URL로 변환
$requestUrl = $baseUrl.'/login';
$headers = ['Accept' => 'application/json', 'Content-Type' => 'application/json'];
if ($this->isDockerEnvironment()) {
$parsedUrl = parse_url($baseUrl);
$host = $parsedUrl['host'] ?? '';
// *.sam.kr 도메인을 nginx 컨테이너로 라우팅
if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') {
$headers['Host'] = $host;
$requestUrl = preg_replace('#https?://[^/]+#', 'https://nginx', $baseUrl).'/login';
}
}
// API 서버에 로그인 요청
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)
->withoutVerifying() // Docker 내부 SSL 인증서 무시
->timeout(10)
->post($requestUrl, [
'user_id' => $validated['user_id'],
'user_pwd' => $validated['user_pwd'],
]);
if (! $response->successful()) {
$body = $response->json();
return response()->json([
'success' => false,
'message' => $body['message'] ?? '로그인 실패: '.$response->status(),
], 401);
}
$body = $response->json();
$token = $body['access_token'] ?? $body['data']['token'] ?? null;
if (! $token) {
return response()->json([
'success' => false,
'message' => '토큰을 받지 못했습니다. 응답: '.json_encode($body, JSON_UNESCAPED_UNICODE),
], 500);
}
// 세션에 저장 (API Explorer와 공유)
session([
'api_explorer_token' => $token,
'api_explorer_user_id' => $body['user']['id'] ?? null,
'api_explorer_user_name' => $body['user']['name'] ?? $validated['user_id'],
]);
return response()->json([
'success' => true,
'message' => 'API 서버 로그인 성공!',
'user' => $body['user'] ?? ['name' => $validated['user_id']],
'token_preview' => substr($token, 0, 20).'...',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'API 서버 연결 실패: '.$e->getMessage(),
], 500);
}
if (! $tenantId) {
return response()->json([]);
}
// user_tenants 피벗 테이블을 통해 해당 테넌트의 사용자 조회
$users = \App\Models\User::whereHas('tenants', function ($query) use ($tenantId) {
$query->where('tenants.id', $tenantId)
->where('user_tenants.is_active', true);
})
->select(['id', 'name', 'email'])
->orderBy('name')
->limit(100)
->get();
return response()->json($users);
}
/**
* 사용자 선택 (Sanctum 토큰 발급)
* API Base URL 결정
*/
public function selectUser(Request $request)
private function getApiBaseUrl(): string
{
$validated = $request->validate([
'user_id' => 'required|integer',
]);
$user = \App\Models\User::find($validated['user_id']);
if (! $user) {
return response()->json([
'success' => false,
'message' => '사용자를 찾을 수 없습니다.',
], 404);
// 환경변수 우선
$envUrl = env('FLOW_TESTER_API_BASE_URL');
if ($envUrl) {
return rtrim($envUrl, '/');
}
// Sanctum 토큰 발급
$token = $user->createToken('flow-tester', ['*'])->plainTextToken;
// config에서 로컬 환경 URL
$environments = config('api-explorer.default_environments', []);
foreach ($environments as $env) {
if ($env['name'] === '로컬') {
return rtrim($env['base_url'], '/');
}
}
// 세션에 저장 (API Explorer와 공유)
session([
'api_explorer_token' => $token,
'api_explorer_user_id' => $user->id,
]);
// 기본값
return 'https://api.sam.kr';
}
return response()->json([
'success' => true,
'message' => "'{$user->name}' 사용자로 인증되었습니다.",
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token_preview' => substr($token, 0, 20).'...',
]);
/**
* Docker 환경인지 확인
*/
private function isDockerEnvironment(): bool
{
return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true');
}
/**

View File

@@ -311,7 +311,8 @@ private function executeStep(array $step): array
* 세션 인증 스텝 실행
*
* useSessionAuth: true 옵션이 있는 login 스텝에서 사용
* 실제 API 호출 없이 세션에 저장된 토큰을 바인딩합니다.
* - 세션 토큰이 있으면 → 세션 토큰 사용 (API 호출 스킵)
* - 세션 토큰이 없으면 → .env 크레덴셜로 실제 API 로그인
*
* @param array $step 스텝 정의
* @param string $stepId 스텝 ID
@@ -324,55 +325,142 @@ private function executeSessionAuthStep(array $step, string $stepId, string $ste
// 세션 인증 정보 조회
$sessionAuth = $this->binder->getSessionAuth();
// 세션 토큰이 으면 실패
if (empty($sessionAuth['token'])) {
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
'error' => '세션 인증 정보가 없습니다. 페이지에서 먼저 인증을 완료해주세요.',
'reason' => '✗ 세션 토큰 없음 - 페이지 인증 필요',
// 세션 토큰이 으면 사용 (API 호출 스킵)
if (! empty($sessionAuth['token'])) {
$extracted = [
'token' => $sessionAuth['token'],
'access_token' => $sessionAuth['token'],
];
if ($sessionAuth['user']) {
$extracted['user_id'] = $sessionAuth['user']['id'];
$extracted['user_name'] = $sessionAuth['user']['name'];
$extracted['user_email'] = $sessionAuth['user']['email'];
}
if ($sessionAuth['tenant_id']) {
$extracted['tenant_id'] = $sessionAuth['tenant_id'];
}
$this->binder->setStepResult($stepId, $extracted, [
'message' => '세션 인증 사용',
'user' => $sessionAuth['user'],
'tenant_id' => $sessionAuth['tenant_id'],
]);
return $this->buildStepResult($stepId, $stepName, $startTime, true, [
'description' => $step['description'] ?? '세션 인증 정보 사용',
'reason' => '✓ 세션 인증 사용 (API 호출 생략)',
'useSessionAuth' => true,
'sessionUser' => $sessionAuth['user'],
'extracted' => $extracted,
'response' => [
'status' => 200,
'body' => [
'message' => '세션 인증 정보를 사용합니다.',
'access_token' => substr($sessionAuth['token'], 0, 20).'...',
'user' => $sessionAuth['user'],
'tenant_id' => $sessionAuth['tenant_id'],
],
'duration' => 0,
],
]);
}
// 세션 토큰을 스텝 결과로 바인딩 ({{login.token}} 등에서 사용 가능)
$extracted = [
'token' => $sessionAuth['token'],
'access_token' => $sessionAuth['token'], // 호환성을 위해 추가
];
// 세션 토큰이 없으면 .env로 폴백하여 실제 API 로그인
// useSessionAuth 옵션을 제거하고 일반 스텝처럼 실행
$fallbackStep = $step;
unset($fallbackStep['useSessionAuth']);
$fallbackStep['description'] = '.env 크레덴셜로 API 로그인 (세션 토큰 없음)';
// 사용자 정보도 바인딩
if ($sessionAuth['user']) {
$extracted['user_id'] = $sessionAuth['user']['id'];
$extracted['user_name'] = $sessionAuth['user']['name'];
$extracted['user_email'] = $sessionAuth['user']['email'];
}
if ($sessionAuth['tenant_id']) {
$extracted['tenant_id'] = $sessionAuth['tenant_id'];
}
return $this->executeLoginStep($fallbackStep, $stepId, $stepName, $startTime);
}
// 스텝 결과 저장 (다음 스텝에서 {{login.token}} 등으로 참조 가능)
$this->binder->setStepResult($stepId, $extracted, [
'message' => '세션 인증 사용',
'user' => $sessionAuth['user'],
'tenant_id' => $sessionAuth['tenant_id'],
]);
/**
* 일반 로그인 스텝 실행 (실제 API 호출)
*
* @param array $step 스텝 정의
* @param string $stepId 스텝 ID
* @param string $stepName 스텝 이름
* @param float $startTime 시작 시간
* @return array 스텝 결과
*/
private function executeLoginStep(array $step, string $stepId, string $stepName, float $startTime): array
{
try {
// 딜레이 적용
$delay = $step['delay'] ?? 0;
if ($delay > 0) {
usleep($delay * 1000);
}
return $this->buildStepResult($stepId, $stepName, $startTime, true, [
'description' => $step['description'] ?? '세션 인증 정보 사용',
'reason' => '✓ 세션 인증 사용 (API 호출 생략)',
'useSessionAuth' => true,
'sessionUser' => $sessionAuth['user'],
'extracted' => $extracted,
'response' => [
'status' => 200,
'body' => [
'message' => '세션 인증 정보를 사용합니다.',
'access_token' => substr($sessionAuth['token'], 0, 20).'...',
'user' => $sessionAuth['user'],
'tenant_id' => $sessionAuth['tenant_id'],
// 변수 바인딩
$endpoint = $this->binder->bind($step['endpoint']);
$headers = $this->binder->bind($step['headers'] ?? []);
$body = $this->binder->bind($step['body'] ?? []);
$query = $this->binder->bind($step['query'] ?? []);
// HTTP 요청 실행
$method = strtoupper($step['method']);
$response = $this->httpClient->request($method, $endpoint, [
'headers' => $headers,
'body' => $body,
'query' => $query,
]);
// HTTP 에러 체크
if ($response['error']) {
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
'error' => $response['error'],
'reason' => '✗ .env 폴백 로그인 실패: '.$response['error'],
'request' => [
'method' => $method,
'endpoint' => $endpoint,
'headers' => $headers,
'body' => $body,
],
]);
}
// 응답 검증
$expect = $step['expect'] ?? ['status' => [200, 201]];
$validation = $this->validator->validate($response, $expect);
// 변수 추출
$extracted = [];
if (isset($step['extract'])) {
$extracted = $this->validator->extractValues($response['body'], $step['extract']);
$this->binder->setStepResult($stepId, $extracted, $response['body']);
}
$success = $validation['success'];
return $this->buildStepResult($stepId, $stepName, $startTime, $success, [
'description' => $step['description'] ?? '.env 크레덴셜로 API 로그인',
'reason' => $success
? '✓ .env 폴백 로그인 성공'
: '✗ .env 폴백 로그인 실패: '.implode('; ', $validation['errors']),
'fallbackLogin' => true,
'request' => [
'method' => $method,
'endpoint' => $endpoint,
'headers' => $headers,
'body' => $body,
],
'duration' => 0,
],
]);
'response' => [
'status' => $response['status'],
'body' => $response['body'],
'duration' => $response['duration'],
],
'extracted' => $extracted,
'validation' => $validation,
'error' => $success ? null : implode('; ', $validation['errors']),
]);
} catch (Exception $e) {
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
'error' => $e->getMessage(),
'reason' => '✗ .env 폴백 로그인 예외: '.$e->getMessage(),
]);
}
}
/**
@@ -476,24 +564,14 @@ private function applyConfig(array $config): void
/**
* 기본 Bearer 토큰 조회
* 우선순위: 세션 토큰 → 사용자 api_token → .env FLOW_TESTER_API_TOKEN
*
* 세션 토큰만 사용 (API 서버 로그인으로 발급받은 토큰)
* .env 폴백은 MNG 토큰이 API 서버에서 인식되지 않으므로 제거됨
*/
private function getDefaultBearerToken(): ?string
{
// 1. 세션에 저장된 토큰 (API Explorer/Flow Tester 인증 모달에서 저장)
$sessionToken = session('api_explorer_token');
if (! empty($sessionToken)) {
return $sessionToken;
}
// 2. 로그인 사용자의 api_token
$user = auth()->user();
if ($user && ! empty($user->api_token)) {
return $user->api_token;
}
// 3. 환경변수 기본 토큰 (fallback)
return env('FLOW_TESTER_API_TOKEN');
// 세션에 저장된 토큰 (API 서버 로그인으로 발급받은 토큰)
return session('api_explorer_token') ?: null;
}
/**

48
composer.lock generated
View File

@@ -1337,16 +1337,16 @@
},
{
"name": "laravel/framework",
"version": "v12.42.0",
"version": "v12.43.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "509b33095564c5165366d81bbaa0afaac28abe75"
"reference": "195b893593a9298edee177c0844132ebaa02102f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75",
"reference": "509b33095564c5165366d81bbaa0afaac28abe75",
"url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f",
"reference": "195b893593a9298edee177c0844132ebaa02102f",
"shasum": ""
},
"require": {
@@ -1555,7 +1555,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-12-09T15:51:23+00:00"
"time": "2025-12-16T18:53:08+00:00"
},
{
"name": "laravel/prompts",
@@ -3457,16 +3457,16 @@
},
{
"name": "psy/psysh",
"version": "v0.12.16",
"version": "v0.12.18",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67"
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196",
"shasum": ""
},
"require": {
@@ -3530,9 +3530,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.16"
"source": "https://github.com/bobthecow/psysh/tree/v0.12.18"
},
"time": "2025-12-07T03:39:01+00:00"
"time": "2025-12-17T14:35:46+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -3656,20 +3656,20 @@
},
{
"name": "ramsey/uuid",
"version": "4.9.1",
"version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
"reference": "8429c78ca35a09f27565311b98101e2826affde0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
"reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
"reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": ""
},
"require": {
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -3728,22 +3728,22 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.9.1"
"source": "https://github.com/ramsey/uuid/tree/4.9.2"
},
"time": "2025-09-04T20:59:21+00:00"
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "spatie/laravel-permission",
"version": "6.23.0",
"version": "6.24.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-permission.git",
"reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb"
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/9e41247bd512b1e6c229afbc1eb528f7565ae3bb",
"reference": "9e41247bd512b1e6c229afbc1eb528f7565ae3bb",
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
"shasum": ""
},
"require": {
@@ -3805,7 +3805,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-permission/issues",
"source": "https://github.com/spatie/laravel-permission/tree/6.23.0"
"source": "https://github.com/spatie/laravel-permission/tree/6.24.0"
},
"funding": [
{
@@ -3813,7 +3813,7 @@
"type": "github"
}
],
"time": "2025-11-03T20:16:13+00:00"
"time": "2025-12-13T21:45:21+00:00"
},
{
"name": "swagger-api/swagger-ui",

View File

@@ -454,21 +454,22 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
</button>
</div>
<!-- 사용자 선택 (API Explorer와 공유) -->
<!-- API 서버 로그인 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
사용자 선택
<label class="block text-sm font-medium text-gray-700 mb-2">
API 서버 로그인
<span class="text-xs text-gray-500 font-normal ml-1">(API Explorer와 공유)</span>
</label>
<div class="flex gap-2">
<select id="userSelect" class="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">-- 사용자를 선택하세요 --</option>
</select>
<button type="button" onclick="selectUser()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition text-sm">
선택
<div class="space-y-2">
<input type="text" id="authUserId" placeholder="사용자 ID"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="password" id="authUserPwd" placeholder="비밀번호"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="loginToApi()" class="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition text-sm">
API 서버 로그인
</button>
</div>
<p class="mt-1 text-xs text-gray-500">사용자를 선택하면 해당 사용자의 Sanctum 토큰이 자동 발급니다.</p>
<p class="mt-1 text-xs text-gray-500">API 서버에 직접 로그인하여 토큰을 발급받습니다.</p>
</div>
<!-- 구분선 -->
@@ -775,81 +776,58 @@ function confirmDelete(id, name) {
// 현재 토큰 상태
let currentAuthToken = @json($savedToken ?? '');
let usersLoaded = false;
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
document.getElementById('authBearerToken').value = currentAuthToken || '';
// 사용자 목록 로딩 (최초 1회)
if (!usersLoaded) {
loadUsers();
}
}
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
// 사용자 목록 로딩
function loadUsers() {
const select = document.getElementById('userSelect');
select.innerHTML = '<option value="">로딩 중...</option>';
// API 서버 로그인
function loginToApi() {
const userId = document.getElementById('authUserId').value.trim();
const userPwd = document.getElementById('authUserPwd').value;
fetch('{{ route("dev-tools.flow-tester.users") }}', {
headers: {
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(users => {
select.innerHTML = '<option value="">-- 사용자를 선택하세요 --</option>';
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name} (${user.email})`;
select.appendChild(option);
});
usersLoaded = true;
})
.catch(error => {
select.innerHTML = '<option value="">사용자 로딩 실패</option>';
console.error('사용자 목록 로딩 실패:', error);
});
}
// 사용자 선택 (Sanctum 토큰 발급)
function selectUser() {
const select = document.getElementById('userSelect');
const userId = select.value;
if (!userId) {
showToast('사용자를 선택해주세요.', 'warning');
if (!userId || !userPwd) {
showToast('사용자 ID와 비밀번호를 입력해주세요.', 'warning');
return;
}
fetch('{{ route("dev-tools.flow-tester.user.select") }}', {
// 버튼 로딩 상태
const btn = event.target;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '로그인 중...';
fetch('{{ route("dev-tools.flow-tester.login-to-api") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ user_id: parseInt(userId) }),
body: JSON.stringify({ user_id: userId, user_pwd: userPwd }),
})
.then(response => response.json())
.then(data => {
btn.disabled = false;
btn.textContent = originalText;
if (data.success) {
// 페이지 새로고침하여 상태 반영
showToast(data.message, 'success');
setTimeout(() => {
location.reload();
}, 500);
} else {
showToast(data.message || '사용자 선택 실패', 'error');
showToast(data.message || 'API 로그인 실패', 'error');
}
})
.catch(error => {
btn.disabled = false;
btn.textContent = originalText;
showToast('오류 발생: ' + error.message, 'error');
});
}

View File

@@ -307,9 +307,8 @@
Route::post('/', [FlowTesterController::class, 'store'])->name('store');
Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json');
// 토큰 및 사용자 관리 라우트 (API Explorer와 공유)
Route::get('/users', [FlowTesterController::class, 'users'])->name('users');
Route::post('/user/select', [FlowTesterController::class, 'selectUser'])->name('user.select');
// 토큰 및 인증 관리 라우트 (API Explorer와 공유)
Route::post('/login-to-api', [FlowTesterController::class, 'loginToApi'])->name('login-to-api');
Route::post('/token/save', [FlowTesterController::class, 'saveToken'])->name('token.save');
Route::post('/token/clear', [FlowTesterController::class, 'clearToken'])->name('token.clear');
Route::get('/token/status', [FlowTesterController::class, 'tokenStatus'])->name('token.status');