feat(message_bizppurio): 인증 토큰 캐시 정합성 및 연결 확인 기능 추가

설정 저장 시 토큰 캐시를 무효화하여 비밀번호 변경 후 최대 23시간 옛
자격증명이 재사용되던 문제를 해소하고, 저장된 계정/비밀번호를 그
자리에서 검증하는 "연결 확인" 버튼을 추가한다. 비밀번호 필드 라벨을
G7 로그인 비밀번호와 구분되도록 정정하고, 토큰 발급 실패 시 비즈뿌리오
원문 사유를 노출해 원인 추적을 돕는다.
This commit is contained in:
chym1217
2026-08-06 10:51:14 +09:00
parent 1de67731b7
commit f9695df74a
19 changed files with 1028 additions and 28 deletions
@@ -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,
];
}
@@ -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 키",
@@ -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",
@@ -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,
);
}
}
}
@@ -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');
});
@@ -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);
}
}
@@ -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));
}
}
@@ -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