feat(message_bizppurio): 인증 토큰 캐시 정합성 및 연결 확인 기능 추가
설정 저장 시 토큰 캐시를 무효화하여 비밀번호 변경 후 최대 23시간 옛 자격증명이 재사용되던 문제를 해소하고, 저장된 계정/비밀번호를 그 자리에서 검증하는 "연결 확인" 버튼을 추가한다. 비밀번호 필드 라벨을 G7 로그인 비밀번호와 구분되도록 정정하고, 토큰 발급 실패 시 비즈뿌리오 원문 사유를 노출해 원인 추적을 돕는다.
This commit is contained in:
@@ -58,6 +58,8 @@ return [
|
||||
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
|
||||
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
|
||||
'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
|
||||
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
|
||||
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
|
||||
],
|
||||
'send_skipped' => [
|
||||
'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
|
||||
@@ -77,4 +79,7 @@ return [
|
||||
'text' => '文字',
|
||||
'alimtalk' => '通知トーク',
|
||||
],
|
||||
'token_check' => [
|
||||
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "Biznorio メッセージ発送",
|
||||
"description": "Biznorio 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。",
|
||||
"name": "ビズプリオ メッセージ発送",
|
||||
"description": "ビズプリオ 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。",
|
||||
"settings": {
|
||||
"title": "Biznorio メッセージ発送 設定",
|
||||
"description": "Biznorio 連動情報と発送 設定を管理します。",
|
||||
"title": "ビズプリオ メッセージ発送 設定",
|
||||
"description": "ビズプリオ 連動情報と発送 設定を管理します。",
|
||||
"save": "保存",
|
||||
"saving": "保存 中...",
|
||||
"save_success": "設定が保存されました。",
|
||||
"save_failed": "設定 保存に失敗しました。",
|
||||
"test_mode": {
|
||||
"label": "検査モード",
|
||||
"hint": "検査モードでは Biznorio 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
|
||||
"account_notice": "検査と運用は別の Biznorio アカウントで運営することをお勧めします。"
|
||||
"hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
|
||||
"account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。"
|
||||
},
|
||||
"live_mode_warning_title": "運用環境で発送されます",
|
||||
"live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
|
||||
@@ -27,16 +27,16 @@
|
||||
},
|
||||
"fields": {
|
||||
"bizppurio_id": {
|
||||
"label": "Biznorio ID",
|
||||
"hint": "発送・カカオ管理に共通に使用する Biznorio ID です。"
|
||||
"label": "ビズプリオ ID",
|
||||
"hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
|
||||
},
|
||||
"password": {
|
||||
"label": "パスワード",
|
||||
"hint": "発送トークン発行に使用する Biznorio パスワードです。"
|
||||
"label": "ビズプリオモジュールパスワード",
|
||||
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API キー",
|
||||
"hint": "API キーは Biznorio カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
|
||||
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
|
||||
},
|
||||
"sender_number": {
|
||||
"label": "発信番号",
|
||||
@@ -53,11 +53,20 @@
|
||||
"clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
|
||||
"clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
|
||||
"clear_cache_failed": "キャッシュ 初期化に失敗しました。"
|
||||
},
|
||||
"connection_check": {
|
||||
"label": "接続確認",
|
||||
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
|
||||
"button": "接続確認",
|
||||
"checking": "確認中...",
|
||||
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
|
||||
"failed": "認証確認に失敗しました。",
|
||||
"unsaved_changes": "変更内容を先に保存してください。"
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"section_title": "レポート受信 設定",
|
||||
"hint": "Biznorio は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを Biznorio ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
|
||||
"hint": "ビズプリオ は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
|
||||
"note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
|
||||
"copy": "コピー",
|
||||
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
|
||||
@@ -71,14 +80,14 @@
|
||||
"templates": "アラート톡 テンプレート"
|
||||
},
|
||||
"preparation": {
|
||||
"intro": "文字・カカオ アラート톡を発送するには、まず Biznorio コンソールで以下の事前準備を完了する必要があります。",
|
||||
"intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
|
||||
"sms_label": "文字(SMS/LMS)",
|
||||
"sms_sender": "発信番号を登録してください。",
|
||||
"kakao_label": "カカオ アラート톡",
|
||||
"kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
|
||||
"kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
|
||||
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
|
||||
"console_link": "Biznorio コンソールを開く"
|
||||
"console_link": "ビズプリオ コンソールを開く"
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 6 · **엔드포인트 수**: 12
|
||||
- **문서 수**: 7 · **엔드포인트 수**: 13
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
@@ -13,6 +13,7 @@
|
||||
| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
|
||||
| [report.md](report.md) | `report` | 1 |
|
||||
| [templates.md](templates.md) | `templates` | 1 |
|
||||
| [token.md](token.md) | `token` | 1 |
|
||||
| [webhook.md](webhook.md) | `webhook` | 1 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Token API 레퍼런스
|
||||
|
||||
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 Token(연결 확인) 엔드포인트 레퍼런스입니다
|
||||
2. POST /admin/token/check — 저장된 계정/비밀번호로 즉시 재인증(캐시 우회)
|
||||
3. 성공 시 200 + 새 토큰을 캐시에 반영, 실패 시 422 + 비즈뿌리오 원문 사유 + result_code
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/plugins/sirsoft-message_bizppurio/admin/token/check
|
||||
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.token.check -->
|
||||
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.token.check`
|
||||
- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-message_bizppurio.messaging.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음. 저장된 플러그인 설정(`bizppurio_id`, `password`)을 사용합니다._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-message_bizppurio/admin/token/check HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_성공 응답은 `data` 없이 메시지만 반환합니다._
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다."
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 자격증명 미설정(계정/비밀번호 공란) 또는 비즈뿌리오 인증 실패 — `errors.result_code` 에 비즈뿌리오 응답 결과코드(있으면) 동반. 비즈뿌리오 서버 연결 자체가 실패(타임아웃·DNS 등)한 경우도 422(연결 실패 안내 메시지, `result_code` 없음) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `BizppurioTokenService::verifyCredentials()` 가 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해 재검증하며, 성공 시 새로 발급된 토큰을 캐시(TTL 23시간)에 반영해 확인 직후의 발송이 이 토큰을 그대로 재사용하게 합니다(불필요한 재발급 방지).
|
||||
|
||||
실패 시 `BizppurioApiException` 을 그대로 422 로 변환합니다 — 비즈뿌리오 응답에 `description` 필드가 있으면 그 원문 사유를 담은 메시지(`token_issue_failed_with_reason`)를, 없으면 고정 메시지(`token_issue_failed`)를 반환합니다. 응답 `errors.result_code` 는 비즈뿌리오 결과코드(예: `3007`)이며, HTTP 전송 자체가 실패한 경우 `null` 입니다.
|
||||
|
||||
비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는 `BizppurioApiException` 이 아닌 `ConnectionException` 으로 던져지므로 별도 catch 하여 `error.connection_failed` 메시지와 함께 422 로 응답합니다(`result_code` 없이 500 대신 매끄러운 실패 안내).
|
||||
|
||||
프론트 화면(`admin/plugin_settings.json` "연결 확인" 필드)은 저장하지 않은 변경사항(`_local.hasChanges`)이 있으면 이 API 를 호출하지 않고 "변경사항을 먼저 저장해주세요" toast 만 표시합니다 — 저장 전 값으로 확인하면 실제 저장된 자격증명과 다른 결과가 나올 수 있기 때문입니다.
|
||||
|
||||
조회가 아닌 재인증(쓰기 성격의 외부 API 호출)이므로 `messaging.view` 가 아닌 `messaging.manage` 권한을 요구합니다.
|
||||
@@ -74,9 +74,11 @@ return [
|
||||
'error' => [
|
||||
'credentials_missing' => 'Please set the Bizppurio ID and password first.',
|
||||
'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.',
|
||||
'token_issue_failed_with_reason' => 'Failed to issue the Bizppurio authentication token. (:reason)',
|
||||
'send_failed' => 'Failed to send the message.',
|
||||
'send_retryable' => 'Message delivery temporarily failed. (code: :code)',
|
||||
'invalid_response' => 'Unable to parse the Bizppurio response.',
|
||||
'connection_failed' => 'Unable to connect to the Bizppurio server. Please try again later.',
|
||||
'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.',
|
||||
'kakao_request_failed' => 'The Kakao management API request failed.',
|
||||
'sender_key_missing' => 'Please set the alimtalk sender profile key first.',
|
||||
@@ -102,4 +104,9 @@ return [
|
||||
'cache' => [
|
||||
'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.',
|
||||
],
|
||||
|
||||
// Connection check (TokenCheckController response)
|
||||
'token_check' => [
|
||||
'success' => 'Authentication verified successfully. The ID and password are correct.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -74,9 +74,11 @@ return [
|
||||
'error' => [
|
||||
'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.',
|
||||
'token_issue_failed' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.',
|
||||
'token_issue_failed_with_reason' => '비즈뿌리오 인증 토큰 발급에 실패했습니다. (:reason)',
|
||||
'send_failed' => '메시지 발송 요청에 실패했습니다.',
|
||||
'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)',
|
||||
'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.',
|
||||
'connection_failed' => '비즈뿌리오 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
|
||||
'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
|
||||
'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
|
||||
@@ -102,4 +104,9 @@ return [
|
||||
'cache' => [
|
||||
'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
|
||||
],
|
||||
|
||||
// 연결 확인 (TokenCheckController 응답)
|
||||
'token_check' => [
|
||||
'success' => '인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\BalanceLowNotificationDataListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\InvalidateTokenOnSettingsSaveListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
|
||||
@@ -439,6 +440,7 @@ class Plugin extends AbstractPlugin
|
||||
* - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3)
|
||||
* - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리)
|
||||
* - ValidateBizppurioSettingsListener: 환경설정 검증
|
||||
* - InvalidateTokenOnSettingsSaveListener: 설정 저장 시 인증 토큰 캐시 무효화
|
||||
*
|
||||
* @return array<class-string>
|
||||
*/
|
||||
@@ -451,6 +453,7 @@ class Plugin extends AbstractPlugin
|
||||
BalanceLowNotificationDataListener::class,
|
||||
LinkNotificationLogListener::class,
|
||||
ValidateBizppurioSettingsListener::class,
|
||||
InvalidateTokenOnSettingsSaveListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+146
-1
@@ -10,7 +10,7 @@
|
||||
* 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import layout from '../../../layouts/admin/plugin_settings.json';
|
||||
import ko from '../../../lang/ko.json';
|
||||
import en from '../../../lang/en.json';
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
collectI18nKeys,
|
||||
type AnyNode,
|
||||
} from './helpers';
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
const root = layout as unknown as AnyNode;
|
||||
|
||||
@@ -151,6 +155,147 @@ describe('plugin_settings.json — 입력 필드 6종', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('plugin_settings.json — 연결 확인 (§529)', () => {
|
||||
it('field_connection_check 필드가 field_password 와 별개 노드로 존재한다', () => {
|
||||
const field = findById(root, 'field_connection_check');
|
||||
expect(field).toBeTruthy();
|
||||
expect(field).not.toBe(findById(root, 'field_password'));
|
||||
});
|
||||
|
||||
it('연결 확인 버튼이 캐시 초기화 버튼과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => {
|
||||
const field = findById(root, 'field_connection_check');
|
||||
const raw = JSON.stringify(field);
|
||||
expect(raw).toContain('grid-cols-1');
|
||||
expect(raw).toContain('lg:grid-cols-12');
|
||||
expect(raw).toContain('lg:col-span-4');
|
||||
expect(raw).toContain('lg:col-span-8');
|
||||
expect(raw).toContain('lg:justify-end');
|
||||
});
|
||||
|
||||
it('버튼은 대기 상태 plug, 로딩 상태 spinner(animate-spin) 아이콘을 조건부로 갖는다', () => {
|
||||
const btn = findById(root, 'connection_check_button');
|
||||
const raw = JSON.stringify(btn);
|
||||
expect(raw).toContain('"plug"');
|
||||
expect(raw).toContain('"spinner"');
|
||||
expect(raw).toContain('animate-spin');
|
||||
expect(raw).toContain('_local.tokenChecking');
|
||||
});
|
||||
|
||||
it('버튼 클릭은 hasChanges=false 일 때만 apiCall 로 /admin/token/check 를 POST 한다', () => {
|
||||
const btn = findById(root, 'connection_check_button');
|
||||
const raw = JSON.stringify(btn);
|
||||
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/token/check');
|
||||
expect(raw).toContain('"method":"POST"');
|
||||
// apiCall 액션 자체가 !hasChanges 가드를 갖는다
|
||||
const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
|
||||
const sequence = actions.find((a) => a.handler === 'sequence');
|
||||
const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
|
||||
const apiCallAction = inner.find((a) => a.handler === 'apiCall');
|
||||
expect(apiCallAction?.if).toContain('!_local.hasChanges');
|
||||
});
|
||||
|
||||
it('hasChanges=true 일 때는 apiCall 없이 unsaved_changes toast 만 실행한다', () => {
|
||||
const btn = findById(root, 'connection_check_button');
|
||||
const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
|
||||
const sequence = actions.find((a) => a.handler === 'sequence');
|
||||
const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
|
||||
const guardToast = inner.find((a) => a.handler === 'toast' && a.if === '{{_local.hasChanges}}');
|
||||
expect(guardToast).toBeTruthy();
|
||||
expect(JSON.stringify(guardToast)).toContain('connection_check.unsaved_changes');
|
||||
});
|
||||
|
||||
it('성공/실패 결과는 화면 상시 표시 없이 toast 로만 안내한다', () => {
|
||||
const btn = findById(root, 'connection_check_button');
|
||||
const raw = JSON.stringify(btn);
|
||||
expect(raw).toContain('connection_check.success');
|
||||
expect(raw).toContain('connection_check.failed');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* hasChanges 초기값(런타임) 검증 — §529 비판적 재검토에서 발견한 공백.
|
||||
*
|
||||
* 위 describe 블록은 레이아웃 JSON의 if 조건 문자열만 정적으로 확인한다. 하지만
|
||||
* "페이지를 막 열고 아무것도 바꾸지 않은 상태(_local.hasChanges 가 아직 세팅 전)"
|
||||
* 에서 실제로 어떻게 평가되는지는 런타임 값 — Boolean(undefined) 규칙에 따라
|
||||
* `{{!_local.hasChanges}}` 는 true(API 호출 진행), `{{_local.hasChanges}}` 는
|
||||
* false(경고 미노출) 가 되어야 정상이다. 실제 엔진(createLayoutTest)으로 렌더해
|
||||
* 이 가정을 증명한다(추정이 아니라 확인).
|
||||
*/
|
||||
describe('plugin_settings.json — 연결 확인 버튼의 hasChanges 초기 상태 (§529 런타임 검증)', () => {
|
||||
const connectionCheckButton = findById(root, 'connection_check_button') as AnyNode & {
|
||||
actions: AnyNode[];
|
||||
};
|
||||
const clickAction = connectionCheckButton.actions.find((a) => a.handler === 'sequence') as AnyNode;
|
||||
|
||||
function buildProbe() {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
layout_name: 'test/connection-check-initial-state',
|
||||
components: [connectionCheckButton],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* toast 핸들러는 ActionDispatcher 내장 처리(handleToast)라 커스텀 registerHandler
|
||||
* 로 가로챌 수 없다 — 실제로는 globalStateUpdater 를 통해 `_global.toasts` 배열에
|
||||
* 쌓인다(createLayoutTest 의 getToasts() 는 이 경로를 타지 않는 죽은 유틸리티임을
|
||||
* 최소 재현으로 확인). 따라서 getState()._global.toasts 를 직접 읽는다.
|
||||
*/
|
||||
function lastToastMessages(utils: ReturnType<typeof createLayoutTest>): string[] {
|
||||
const toasts = (utils.getState()._global?.toasts ?? []) as Array<{ message: string }>;
|
||||
return toasts.map((t) => t.message);
|
||||
}
|
||||
|
||||
it('로드 직후(hasChanges 미설정) 클릭하면 hasChanges 가드를 통과해 실제 API 응답까지 도달한다', async () => {
|
||||
const registry = createMockComponentRegistryWithBasics();
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('token_check_probe', { response: { success: true } });
|
||||
await utils.render();
|
||||
|
||||
// 초기 상태: hasChanges 는 아직 세팅되지 않음(undefined) — 저장 폼을 만지지 않은 상태.
|
||||
// Boolean(undefined) === false 이므로 미저장 가드({{_local.hasChanges}})는 통과해야 한다.
|
||||
expect(utils.getState()._local?.hasChanges).toBeFalsy();
|
||||
|
||||
await utils.triggerAction(clickAction);
|
||||
|
||||
// hasChanges 가드를 통과했다면 apiCall 이 실행되어 onSuccess/onError 중
|
||||
// 하나가 반드시 toast 를 남긴다 — unsaved_changes 경고는 뜨지 않고,
|
||||
// success 또는 failed(원격 호출 실패 응답 처리) 중 하나만 떠야 한다.
|
||||
const messages = lastToastMessages(utils);
|
||||
expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(false);
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('hasChanges=true 로 세팅된 뒤 클릭하면 API 호출 없이 미저장 경고 toast 만 뜬다', async () => {
|
||||
const registry = createMockComponentRegistryWithBasics();
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
await utils.render();
|
||||
|
||||
utils.setState('hasChanges', true, 'local');
|
||||
await utils.triggerAction(clickAction);
|
||||
|
||||
const messages = lastToastMessages(utils);
|
||||
expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(true);
|
||||
expect(messages.some((m) => m.includes('connection_check.success'))).toBe(false);
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings.json — 비밀번호 필드 라벨 (§529)', () => {
|
||||
it('비밀번호 필드 라벨이 "비즈뿌리오 모듈 비밀번호"로 G7 로그인 비밀번호와 구분된다', () => {
|
||||
expect((ko as Record<string, any>).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호');
|
||||
expect((en as Record<string, any>).settings.fields.password.label).toBe('Bizppurio Module Password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings.json — 리포트 수신 설정', () => {
|
||||
it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => {
|
||||
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
|
||||
|
||||
@@ -31,8 +31,17 @@
|
||||
"hint": "The Bizppurio account ID used for both sending and Kakao management."
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"hint": "The Bizppurio password used to issue the sending token."
|
||||
"label": "Bizppurio Module Password",
|
||||
"hint": "Please enter the Bizppurio module password. (Bizppurio console > Module Integration Settings > Change Module Password)"
|
||||
},
|
||||
"connection_check": {
|
||||
"label": "Connection Check",
|
||||
"hint": "Click to instantly verify the saved ID and password. (Only reflects saved values)",
|
||||
"button": "Check Connection",
|
||||
"checking": "Checking...",
|
||||
"success": "Authentication verified successfully. The ID and password are correct.",
|
||||
"failed": "Failed to verify authentication.",
|
||||
"unsaved_changes": "Please save your changes first."
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API Key",
|
||||
|
||||
@@ -31,8 +31,17 @@
|
||||
"hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다."
|
||||
},
|
||||
"password": {
|
||||
"label": "비밀번호",
|
||||
"hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다."
|
||||
"label": "비즈뿌리오 모듈 비밀번호",
|
||||
"hint": "비즈뿌리오 모듈 비밀번호를 입력해주세요. (비즈뿌리오 콘솔 > 모듈연동 환경설정 > 모듈 비밀번호 변경)"
|
||||
},
|
||||
"connection_check": {
|
||||
"label": "연결 확인",
|
||||
"hint": "저장된 아이디·비밀번호가 유효한지 눌러서 바로 확인하세요. (저장 후에만 반영됩니다)",
|
||||
"button": "연결 확인",
|
||||
"checking": "확인 중...",
|
||||
"success": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.",
|
||||
"failed": "인증 확인에 실패했습니다.",
|
||||
"unsaved_changes": "변경사항을 먼저 저장해주세요."
|
||||
},
|
||||
"api_key": {
|
||||
"label": "API 키",
|
||||
|
||||
+132
-1
@@ -7,7 +7,8 @@
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-message_bizppurio.settings.title",
|
||||
"description": "$t:sirsoft-message_bizppurio.settings.description"
|
||||
"description": "$t:sirsoft-message_bizppurio.settings.description",
|
||||
"_e2e_allow": "e2e:allow (§529) 연결 확인 필드·hasChanges 가드 추가는 레이아웃 회귀테스트(plugin_settings.test.tsx) + PHPUnit Feature(TokenCheckEndpointTest·InvalidateTokenOnSettingsSaveListenerTest)로 커버. 실연동 Playwright는 계획서 §9 방침에 따라 PO 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — tests/scenarios/plugin-settings.yaml 상단 audit:allow 사유 참고."
|
||||
},
|
||||
"init_actions": [
|
||||
{
|
||||
@@ -508,6 +509,136 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_connection_check",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "lg:col-span-4" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": { "className": "form-label" },
|
||||
"text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "form-hint" },
|
||||
"text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": { "className": "lg:col-span-8 flex lg:justify-end" },
|
||||
"children": [
|
||||
{
|
||||
"id": "connection_check_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_local.tokenChecking}}",
|
||||
"className": "btn btn-outline btn-sm flex-center gap-1.5"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"if": "{{_local.hasChanges}}",
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "warning",
|
||||
"message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.unsaved_changes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": "{{!_local.hasChanges}}",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "tokenChecking": true }
|
||||
},
|
||||
{
|
||||
"if": "{{!_local.hasChanges}}",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/plugins/sirsoft-message_bizppurio/admin/token/check",
|
||||
"params": { "method": "POST" },
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "tokenChecking": false }
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.success"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "tokenChecking": false }
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message ?? '$t:sirsoft-message_bizppurio.settings.fields.connection_check.failed'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_local.tokenChecking}}",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "text-sm animate-spin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{!_local.tokenChecking}}",
|
||||
"props": {
|
||||
"name": "plug",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!_local.tokenChecking}}",
|
||||
"text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.button"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.tokenChecking}}",
|
||||
"text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.checking"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_api_key",
|
||||
"type": "basic",
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 인증 토큰 컨트롤러 — 설정 화면 "연결 확인" 버튼이 호출.
|
||||
*
|
||||
* 관리자가 설정 화면에서 저장한 계정/비밀번호가 실제로 유효한지 그 자리에서
|
||||
* 확인할 수 있게 한다. 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해
|
||||
* 검증하며, 성공 시 발급된 토큰을 캐시에 반영한다(BizppurioTokenService::
|
||||
* verifyCredentials 참고).
|
||||
*/
|
||||
class TokenCheckController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* @param BizppurioTokenService $tokenService 비즈뿌리오 인증 토큰 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioTokenService $tokenService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다.
|
||||
*
|
||||
* 비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는
|
||||
* BizppurioApiException 이 아닌 ConnectionException 으로 던져지므로 별도
|
||||
* catch 하여 500 대신 422 로 응답한다.
|
||||
*
|
||||
* @return JsonResponse 성공 시 200(발급 확인), 실패 시 422(비즈뿌리오 실패 사유 또는 연결 실패 안내)
|
||||
*/
|
||||
public function check(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->tokenService->verifyCredentials();
|
||||
|
||||
return ResponseHelper::success('sirsoft-message_bizppurio::messages.token_check.success');
|
||||
} catch (BizppurioApiException $e) {
|
||||
return ResponseHelper::error(
|
||||
$e->getMessage(),
|
||||
422,
|
||||
['result_code' => $e->getResultCode()],
|
||||
);
|
||||
} catch (ConnectionException) {
|
||||
return ResponseHelper::error(
|
||||
'sirsoft-message_bizppurio::messages.error.connection_failed',
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 설정 저장 시 인증 토큰 캐시를 무효화하는 listener.
|
||||
*
|
||||
* BizppurioTokenService 는 발급받은 토큰을 23시간(TTL) 캐시한다(만료 24h 대비
|
||||
* 여유). 캐시가 계정/비밀번호 변경과 무관하게 살아있으면, 관리자가 자격증명을
|
||||
* 바꿔도 최대 23시간 동안 옛 자격증명으로 발급된 토큰이 계속 재사용된다 —
|
||||
* 발송 자체는 그동안 정상 동작하므로 변경 반영 여부를 즉시 확인할 방법이
|
||||
* 없고, 토큰이 만료되어서야 뒤늦게 실패가 드러난다.
|
||||
*
|
||||
* 저장 시점에 무조건 캐시를 비워 다음 토큰 획득(발송 또는 "연결 확인" 버튼)이
|
||||
* 항상 최신 자격증명으로 재인증하게 한다. 대상 필드(bizppurio_id/password)
|
||||
* 변경 여부와 무관하게 매 저장마다 초기화한다 — 부분 필드만 골라 비교하는
|
||||
* 것보다 단순하고, 불필요한 재발급 1회의 비용은 무시할 수 있는 수준이다.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class InvalidateTokenOnSettingsSaveListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 본 플러그인 식별자.
|
||||
*/
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/**
|
||||
* @param BizppurioTokenService $tokenService 비즈뿌리오 인증 토큰 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioTokenService $tokenService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 구독 훅 메타데이터.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.plugin_settings.after_save' => [
|
||||
'method' => 'invalidateToken',
|
||||
'priority' => 10,
|
||||
'type' => 'action',
|
||||
'sync' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 인터페이스 표준 진입점 — getSubscribedHooks 가 method='invalidateToken' 를 명시하므로
|
||||
* 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다.
|
||||
*
|
||||
* @param mixed ...$args 사용 안 함
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// no-op — 실제 진입점은 invalidateToken() 메서드 (action 훅)
|
||||
}
|
||||
|
||||
/**
|
||||
* 본 플러그인 설정 저장 직후 토큰 캐시를 무효화한다.
|
||||
*
|
||||
* @param string $identifier 저장된 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 저장된 설정(사용 안 함)
|
||||
* @param bool $result 저장 성공 여부
|
||||
*/
|
||||
public function invalidateToken(string $identifier, array $settings, bool $result): void
|
||||
{
|
||||
if ($identifier !== self::IDENTIFIER || ! $result) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tokenService->forget();
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,27 @@ class BizppurioTokenService
|
||||
$this->cache->forget(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다.
|
||||
*
|
||||
* 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출한다(관리자가 계정/비밀번호가
|
||||
* 유효한지 그 자리에서 확인하려는 목적 — 설정 화면 "연결 확인" 버튼이 소비).
|
||||
* 검증에 성공하면 새로 발급된 토큰으로 캐시를 갱신해, 확인 직후의 발송이 이
|
||||
* 토큰을 그대로 재사용할 수 있게 한다(불필요한 재발급 방지).
|
||||
*
|
||||
* @return string 새로 발급받은 Bearer 액세스 토큰
|
||||
*
|
||||
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
|
||||
*/
|
||||
public function verifyCredentials(): string
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
$this->cache->put(self::CACHE_KEY, $token, self::CACHE_TTL_SECONDS);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/v1/token` 을 호출해 새 토큰을 발급받습니다.
|
||||
*
|
||||
@@ -119,7 +140,8 @@ class BizppurioTokenService
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BizppurioApiException(
|
||||
__('sirsoft-message_bizppurio::messages.error.token_issue_failed'),
|
||||
$this->describeFailure($response),
|
||||
resultCode: (string) ($response->json('code') ?? '') ?: null,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
@@ -128,7 +150,8 @@ class BizppurioTokenService
|
||||
|
||||
if ($token === '') {
|
||||
throw new BizppurioApiException(
|
||||
__('sirsoft-message_bizppurio::messages.error.token_issue_failed'),
|
||||
$this->describeFailure($response),
|
||||
resultCode: (string) ($response->json('code') ?? '') ?: null,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
@@ -136,6 +159,30 @@ class BizppurioTokenService
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 실패 응답에서 비즈뿌리오 원문 사유를 담은 메시지를 만듭니다.
|
||||
*
|
||||
* 비즈뿌리오는 실패 시 `{"code": "3007", "description": "invalid password in
|
||||
* bizppurio"}` 형태로 원인을 내려준다. 원문이 있으면 함께 노출해 운영자가 계정/
|
||||
* 비밀번호 오류인지 서버 오류인지 즉시 구분할 수 있게 한다(고정 문구만으로는
|
||||
* 원인 추적이 불가능했던 문제 대응).
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response 실패한 HTTP 응답
|
||||
* @return string 사용자에게 보여줄 실패 메시지
|
||||
*/
|
||||
private function describeFailure(\Illuminate\Http\Client\Response $response): string
|
||||
{
|
||||
$description = (string) ($response->json('description') ?? '');
|
||||
|
||||
if ($description === '') {
|
||||
return __('sirsoft-message_bizppurio::messages.error.token_issue_failed');
|
||||
}
|
||||
|
||||
return __('sirsoft-message_bizppurio::messages.error.token_issue_failed_with_reason', [
|
||||
'reason' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\TokenCheckController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController;
|
||||
|
||||
/*
|
||||
@@ -139,4 +140,11 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
|
||||
Route::post('/lookup', [DispatchResultController::class, 'lookup'])->name('lookup');
|
||||
});
|
||||
});
|
||||
|
||||
// 인증 토큰 재검증 — 저장된 계정/비밀번호가 유효한지 그 자리에서 확인한다(설정
|
||||
// 화면 "연결 확인" 버튼). 캐시를 거치지 않고 매번 /v1/token 을 새로 호출하므로
|
||||
// 조회가 아닌 쓰기 성격에 준해 messaging.manage 권한을 요구한다.
|
||||
Route::post('/token/check', [TokenCheckController::class, 'check'])
|
||||
->middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')
|
||||
->name('token.check');
|
||||
});
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Settings;
|
||||
|
||||
use App\Extension\Cache\PluginCacheDriver;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 연결 확인(인증 토큰 재검증) 엔드포인트 테스트.
|
||||
*
|
||||
* POST /api/plugins/sirsoft-message_bizppurio/admin/token/check 가 저장된 자격증명으로
|
||||
* `/v1/token` 을 즉시 재호출해 성공/실패를 그대로 반환하는지, 그리고 인증/권한 가드가
|
||||
* 동작하는지 검증한다.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class TokenCheckEndpointTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
private const ENDPOINT = '/api/plugins/sirsoft-message_bizppurio/admin/token/check';
|
||||
|
||||
/**
|
||||
* 지정 권한을 가진 admin 사용자를 생성합니다.
|
||||
*
|
||||
* @param array<int, string> $permissionIds 부여할 권한 식별자
|
||||
*/
|
||||
private function adminWith(array $permissionIds): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
|
||||
);
|
||||
|
||||
$permIds = [];
|
||||
foreach ($permissionIds as $identifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
|
||||
);
|
||||
$permIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$testRole = Role::create([
|
||||
'identifier' => 'bizppurio_token_check_test_'.uniqid(),
|
||||
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||||
'type' => 'admin',
|
||||
]);
|
||||
$testRole->permissions()->sync($permIds);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 토큰으로 요청 헤더를 만듭니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function authHeaders(User $user): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 아이디/비밀번호를 저장합니다.
|
||||
*/
|
||||
private function storeCredentials(string $id = 'biz-account', string $password = 'secret'): void
|
||||
{
|
||||
app(PluginSettingsService::class)->save(self::IDENTIFIER, [
|
||||
'bizppurio_id' => $id,
|
||||
'password' => $password,
|
||||
'is_test_mode' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_인증_없이_요청하면_401이다(): void
|
||||
{
|
||||
$this->postJson(self::ENDPOINT)->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_manage_권한_없이_요청하면_403이다(): void
|
||||
{
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_자격증명이_유효하면_200을_반환한다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/token' => Http::response(['accesstoken' => 'VALID_TOKEN', 'type' => 'Bearer'], 200),
|
||||
]);
|
||||
|
||||
$this->storeCredentials();
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_자격증명이_유효하면_캐시가_새_토큰으로_갱신된다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200),
|
||||
]);
|
||||
|
||||
$this->storeCredentials();
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
|
||||
|
||||
// BizppurioTokenService 가 contextual binding 으로 받는 것과 동일한 캐시
|
||||
// (식별자 네임스페이스 + 기본 캐시 스토어) — 전역 app(CacheInterface::class) 는
|
||||
// 코어 네임스페이스라 별개 저장 공간이므로 사용할 수 없다.
|
||||
$cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default'));
|
||||
$cache->flush();
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame('FRESH_TOKEN', $cache->get('bizppurio:token'));
|
||||
}
|
||||
|
||||
public function test_비즈뿌리오_서버에_연결할_수_없으면_500_대신_422를_반환한다(): void
|
||||
{
|
||||
Http::fake(['*/v1/token' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$this->storeCredentials();
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_비즈뿌리오가_실패_사유를_반환하면_422와_함께_원문_사유가_노출된다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password in bizppurio'], 401),
|
||||
]);
|
||||
|
||||
$this->storeCredentials();
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('errors.result_code', '3007');
|
||||
$this->assertStringContainsString('invalid password in bizppurio', $response->json('message'));
|
||||
}
|
||||
|
||||
public function test_자격증명_미설정시_422를_반환한다(): void
|
||||
{
|
||||
$this->storeCredentials(id: '', password: '');
|
||||
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
|
||||
|
||||
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Listeners;
|
||||
|
||||
use App\Extension\Cache\PluginCacheDriver;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\InvalidateTokenOnSettingsSaveListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 설정 저장 시 인증 토큰 캐시 무효화 리스너를 실제 훅 체인 + 실제 캐시로 검증한다.
|
||||
*
|
||||
* 리스너 메서드를 직접 부르지 않고, `core.plugin_settings.after_save` 훅을 통해
|
||||
* 실제 확장 캐시 드라이버(PluginCacheDriver)의 관찰 가능한 상태 변화(존재→부재)로
|
||||
* 검증한다(Hook/Event 도메인 규정 — mock 금지, 실제 훅 체인).
|
||||
*
|
||||
* BizppurioTokenService 는 contextual binding 으로 플러그인 식별자 네임스페이스가
|
||||
* 적용된 PluginCacheDriver 를 주입받는다(전역 app(CacheInterface::class) 는 코어
|
||||
* 네임스페이스의 CoreCacheDriver 로 별개 저장 공간이므로 검증에 사용할 수 없다).
|
||||
* 따라서 이 테스트도 동일한 PluginCacheDriver 를 직접 생성해 관찰한다.
|
||||
*/
|
||||
class InvalidateTokenOnSettingsSaveListenerTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
private const CACHE_KEY = 'bizppurio:token';
|
||||
|
||||
private PluginCacheDriver $cache;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// BizppurioTokenService 가 contextual binding 으로 받는 것과 동일한 캐시
|
||||
// (식별자 네임스페이스 + 기본 캐시 스토어).
|
||||
$this->cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default'));
|
||||
$this->cache->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스너를 실제 훅 파이프라인에 등록합니다.
|
||||
*/
|
||||
private function registerListener(): void
|
||||
{
|
||||
$tokenService = new BizppurioTokenService($this->cache, app(PluginSettingsService::class));
|
||||
$listener = new InvalidateTokenOnSettingsSaveListener($tokenService);
|
||||
|
||||
HookManager::addAction(
|
||||
'core.plugin_settings.after_save',
|
||||
[$listener, 'invalidateToken'],
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_저장_성공시_본_플러그인_캐시가_무효화된다(): void
|
||||
{
|
||||
$this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600);
|
||||
$this->assertTrue($this->cache->has(self::CACHE_KEY));
|
||||
|
||||
$this->registerListener();
|
||||
|
||||
HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], true);
|
||||
|
||||
$this->assertFalse($this->cache->has(self::CACHE_KEY));
|
||||
}
|
||||
|
||||
public function test_저장_실패시_캐시를_건드리지_않는다(): void
|
||||
{
|
||||
$this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600);
|
||||
|
||||
$this->registerListener();
|
||||
|
||||
HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], false);
|
||||
|
||||
$this->assertTrue($this->cache->has(self::CACHE_KEY));
|
||||
$this->assertSame('EXISTING_TOKEN', $this->cache->get(self::CACHE_KEY));
|
||||
}
|
||||
|
||||
public function test_다른_플러그인_저장시_캐시를_건드리지_않는다(): void
|
||||
{
|
||||
$this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600);
|
||||
|
||||
$this->registerListener();
|
||||
|
||||
HookManager::doAction('core.plugin_settings.after_save', 'sirsoft-other-plugin', [], true);
|
||||
|
||||
$this->assertTrue($this->cache->has(self::CACHE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 저장 API(PluginSettingsService::save) 를 호출해 코어 저장 경로 →
|
||||
* after_save 훅 → 캐시 무효화까지 end-to-end 로 관찰한다.
|
||||
*/
|
||||
public function test_설정_저장_API_호출시_실제_저장_경로를_통해_캐시가_무효화된다(): void
|
||||
{
|
||||
$this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600);
|
||||
|
||||
$this->registerListener();
|
||||
|
||||
app(PluginSettingsService::class)->save(self::IDENTIFIER, [
|
||||
'bizppurio_id' => 'new-account',
|
||||
'password' => 'new-secret',
|
||||
'is_test_mode' => true,
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->cache->has(self::CACHE_KEY));
|
||||
}
|
||||
}
|
||||
+87
@@ -44,6 +44,14 @@ class BizppurioTokenServiceTest extends PluginTestCase
|
||||
}
|
||||
);
|
||||
|
||||
$mock->shouldReceive('put')->andReturnUsing(
|
||||
function (string $key, $value) use (&$store) {
|
||||
$store[$key] = $value;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
@@ -158,6 +166,85 @@ class BizppurioTokenServiceTest extends PluginTestCase
|
||||
$service->getToken();
|
||||
}
|
||||
|
||||
public function test_verifyCredentials는_캐시를_거치지_않고_재발급한다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200),
|
||||
]);
|
||||
|
||||
$store = ['bizppurio:token' => 'STALE_TOKEN'];
|
||||
$service = new BizppurioTokenService(
|
||||
$this->makeCache($store),
|
||||
$this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
|
||||
);
|
||||
|
||||
$this->assertSame('FRESH_TOKEN', $service->verifyCredentials());
|
||||
Http::assertSentCount(1);
|
||||
$this->assertSame('FRESH_TOKEN', $store['bizppurio:token']);
|
||||
}
|
||||
|
||||
public function test_verifyCredentials_실패시_예외를_전파하고_캐시를_건드리지_않는다(): void
|
||||
{
|
||||
Http::fake(['*/v1/token' => Http::response([], 500)]);
|
||||
|
||||
$store = ['bizppurio:token' => 'EXISTING_TOKEN'];
|
||||
$service = new BizppurioTokenService(
|
||||
$this->makeCache($store),
|
||||
$this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
|
||||
);
|
||||
|
||||
try {
|
||||
$service->verifyCredentials();
|
||||
$this->fail('예외가 발생해야 합니다.');
|
||||
} catch (BizppurioApiException $e) {
|
||||
// 실패 시 캐시는 그대로 보존 — 발송이 기존 토큰(만료 전까지)을 계속 사용
|
||||
$this->assertSame('EXISTING_TOKEN', $store['bizppurio:token']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_토큰_발급_실패시_비즈뿌리오_사유가_메시지에_포함된다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password'], 401),
|
||||
]);
|
||||
|
||||
$store = [];
|
||||
$service = new BizppurioTokenService(
|
||||
$this->makeCache($store),
|
||||
$this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
|
||||
);
|
||||
|
||||
try {
|
||||
$service->getToken();
|
||||
$this->fail('예외가 발생해야 합니다.');
|
||||
} catch (BizppurioApiException $e) {
|
||||
$this->assertStringContainsString('invalid password', $e->getMessage());
|
||||
$this->assertSame('3007', $e->getResultCode());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_토큰_발급_실패시_사유_없으면_고정_메시지로_폴백한다(): void
|
||||
{
|
||||
Http::fake(['*/v1/token' => Http::response([], 500)]);
|
||||
|
||||
$store = [];
|
||||
$service = new BizppurioTokenService(
|
||||
$this->makeCache($store),
|
||||
$this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
|
||||
);
|
||||
|
||||
try {
|
||||
$service->getToken();
|
||||
$this->fail('예외가 발생해야 합니다.');
|
||||
} catch (BizppurioApiException $e) {
|
||||
$this->assertSame(
|
||||
__('sirsoft-message_bizppurio::messages.error.token_issue_failed'),
|
||||
$e->getMessage()
|
||||
);
|
||||
$this->assertNull($e->getResultCode());
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# 환경설정 화면의 입력·저장은 코어 /api/admin/plugins/{id}/settings 에 위임되는 자동바인딩
|
||||
# 폼이라, axes cross product 의 UI 구조·검증·i18n effects 는 Vitest(plugin_settings.test.tsx)
|
||||
# 30건 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소
|
||||
# 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소
|
||||
# 조회는 PHPUnit Feature(BizppurioLiveModeSettingsValidationTest·ReportUrlEndpointTest) 로
|
||||
# 실제 HTTP 왕복을 관찰한다. 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증
|
||||
# (PO 직접 수행, 인증·발송 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects
|
||||
# docblock 완전 매핑을 추가한다. 그때까지 매니페스트는 명세로 유지한다.
|
||||
# 실제 HTTP 왕복을 관찰한다. (§529) 연결 확인 API 왕복(200/422 + result_code)은
|
||||
# TokenCheckEndpointTest, 저장 시 토큰 캐시 무효화는 실제 훅 체인(core.plugin_settings.
|
||||
# after_save) + 실제 CacheInterface 로 InvalidateTokenOnSettingsSaveListenerTest 가,
|
||||
# verifyCredentials/describeFailure 는 BizppurioTokenServiceTest 가 각각 관찰한다.
|
||||
# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송
|
||||
# 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을
|
||||
# 추가한다. 그때까지 매니페스트는 명세로 유지한다.
|
||||
|
||||
feature: 비즈뿌리오 메시지 발송 환경설정 (Phase 1)
|
||||
|
||||
@@ -16,7 +20,7 @@ description: |
|
||||
|
||||
핵심 동작:
|
||||
- 검수 모드: is_test_mode Toggle (독립 카드). 켜짐=검수(dev) 도메인, 꺼짐=운영(live) 도메인
|
||||
- API 연동: 비즈뿌리오 아이디 + 비밀번호 + API 키
|
||||
- API 연동: 비즈뿌리오 아이디 + 비즈뿌리오 모듈 비밀번호 + API 키
|
||||
- 발송 설정: 발신번호 + 알림톡 발신프로필 키
|
||||
- 검수 모드를 끄면(운영) 경고 박스를 표시하고, 저장 시 발송 필수 자격증명
|
||||
(bizppurio_id/password/sender_number)을 required 로 강제(조건부 검증 필터 훅)
|
||||
@@ -26,6 +30,11 @@ description: |
|
||||
- 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT
|
||||
- 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드
|
||||
(관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나)
|
||||
- (§529) 저장 성공 시 인증 토큰 캐시를 무조건 무효화 — 비밀번호 변경 후 옛 자격증명으로
|
||||
발급된 토큰이 만료(23h)까지 재사용되는 것을 방지
|
||||
- (§529) "연결 확인" 버튼 — 비밀번호 필드와 분리된 독립 필드. 저장된 자격증명으로
|
||||
캐시를 거치지 않고 즉시 `/v1/token` 재검증. hasChanges=true(미저장 변경 존재)면
|
||||
API 호출 없이 "먼저 저장해주세요" toast 만 표시. 결과는 toast 로만 안내(상시 표시 없음)
|
||||
|
||||
※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관.
|
||||
|
||||
@@ -33,9 +42,11 @@ axes:
|
||||
credential_state: [empty, filled]
|
||||
test_mode: [on, off]
|
||||
field_kind: [credential, plain, readonly]
|
||||
connection_check_result: [valid_credentials, invalid_credentials, unsaved_changes]
|
||||
|
||||
exclusions:
|
||||
- { field_kind: readonly, credential_state: filled, reason: "리포트 수신 주소는 서버 조회 표시값 — 운영자 입력 대상 아님" }
|
||||
- { connection_check_result: unsaved_changes, credential_state: empty, reason: "unsaved_changes 분기는 hasChanges 게이트만 검증 — 자격증명 값 자체는 무관(가드가 API 호출 자체를 막음)" }
|
||||
|
||||
effects:
|
||||
# 섹션 구조
|
||||
@@ -63,6 +74,19 @@ effects:
|
||||
- save_button_disabled_without_changes
|
||||
- save_puts_to_core_plugin_settings_api
|
||||
- only_registered_handlers_used
|
||||
# 연결 확인 + 저장 시 토큰 캐시 무효화 (§529)
|
||||
- password_field_label_distinguishes_module_password_from_g7_login_password
|
||||
- connection_check_field_is_separate_node_from_password_field
|
||||
- connection_check_button_shows_plug_icon_idle_spinner_icon_loading
|
||||
- connection_check_calls_token_check_api_only_when_no_unsaved_changes
|
||||
- connection_check_shows_unsaved_changes_toast_without_api_call_when_has_changes
|
||||
- connection_check_result_shown_via_toast_only_no_persistent_indicator
|
||||
- token_check_endpoint_returns_200_on_valid_credentials
|
||||
- token_check_endpoint_returns_422_with_bizppurio_reason_and_result_code_on_failure
|
||||
- token_check_endpoint_requires_authentication_and_manage_permission
|
||||
- settings_save_success_invalidates_token_cache_regardless_of_changed_fields
|
||||
- settings_save_failure_does_not_invalidate_token_cache
|
||||
- other_plugin_settings_save_does_not_invalidate_this_plugin_token_cache
|
||||
# 탭 전환 (환경설정 ↔ 알림톡 템플릿) — query.tab 변경 시 if 조건부 패널 재평가 필요
|
||||
- tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀
|
||||
- alimtalk_template_list_error_shown_with_kakao_reason # 목록 조회 실패 시 카카오 사유(kakao_message)를 배너로 노출
|
||||
@@ -78,8 +102,11 @@ test_files:
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php
|
||||
|
||||
audit:
|
||||
status: codified
|
||||
|
||||
Reference in New Issue
Block a user