diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index 5650439c..68315fe1 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -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とパスワードが正しいです。', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json index 0e68d130..25e709b3 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json @@ -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": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index a423a3be..d98fadfc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,7 +4,7 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 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 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md new file mode 100644 index 00000000..37d577b8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md @@ -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 + +- **라우트명**: `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` 없음) | + + + +**설명** + +관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `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` 권한을 요구합니다. \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 31d107c1..69866dc3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -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.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index ebb6ae0a..ec381c7a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -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' => '인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 4450cd31..b5048d2b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -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 */ @@ -451,6 +453,7 @@ class Plugin extends AbstractPlugin BalanceLowNotificationDataListener::class, LinkNotificationLogListener::class, ValidateBizppurioSettingsListener::class, + InvalidateTokenOnSettingsSaveListener::class, ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index ac9954aa..d07a2545 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -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): 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).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호'); + expect((en as Record).settings.fields.password.label).toBe('Bizppurio Module Password'); + }); +}); + describe('plugin_settings.json — 리포트 수신 설정', () => { it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => { const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a28c9e3a..a53bb8b7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -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", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 43e5b8f6..9240029c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -31,8 +31,17 @@ "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." }, "password": { - "label": "비밀번호", - "hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다." + "label": "비즈뿌리오 모듈 비밀번호", + "hint": "비즈뿌리오 모듈 비밀번호를 입력해주세요. (비즈뿌리오 콘솔 > 모듈연동 환경설정 > 모듈 비밀번호 변경)" + }, + "connection_check": { + "label": "연결 확인", + "hint": "저장된 아이디·비밀번호가 유효한지 눌러서 바로 확인하세요. (저장 후에만 반영됩니다)", + "button": "연결 확인", + "checking": "확인 중...", + "success": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.", + "failed": "인증 확인에 실패했습니다.", + "unsaved_changes": "변경사항을 먼저 저장해주세요." }, "api_key": { "label": "API 키", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 744dfaf0..2f8d8799 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -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", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php new file mode 100644 index 00000000..ce26691f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php @@ -0,0 +1,61 @@ +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, + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php new file mode 100644 index 00000000..ac92c6f6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php @@ -0,0 +1,83 @@ +> + */ + 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 $settings 저장된 설정(사용 안 함) + * @param bool $result 저장 성공 여부 + */ + public function invalidateToken(string $identifier, array $settings, bool $result): void + { + if ($identifier !== self::IDENTIFIER || ! $result) { + return; + } + + $this->tokenService->forget(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php index fa85e137..0f715758 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -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 을 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 63dfb6a5..262d94b7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -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'); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php new file mode 100644 index 00000000..bb3d2da3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php @@ -0,0 +1,177 @@ + $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 + */ + 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); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php new file mode 100644 index 00000000..419ad67d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php @@ -0,0 +1,110 @@ +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)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php index e3c1dffc..6d453d83 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -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(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index 7e6b2b3c..7c0cc219 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -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