From fd7e216639d87917e922a15eaa6941eb0232cd57 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 13 Jul 2026 10:08:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20KG=EC=9D=B4=EB=8B=88=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=EC=8B=9D=20=EA=B0=9C=ED=8E=B8=20+=20=EA=B2=80=EC=88=98=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면을 공용 클래스·2단 그리드·상단 안내 패널·필드 인라인 에러로 통일 - 연동 환경(dev/live)을 검수 모드 Toggle(is_test_mode)로 전환, 독립 카드로 분리하고 운영 경고 박스를 API 연동 카드 상단에 배치 - 검수 모드를 끄면(운영) 발송 자격증명(아이디·비밀번호·발신번호)을 required 로 강제하는 조건부 검증 필터 훅 추가 - 리포트 수신 주소를 설정 화면에서 확인·복사 (플러그인 라우트가 config('app.url') 기준 절대 URL 반환, 코어 무수정) - 명칭 "메시징→메시지 발송", 설명 "자동발송→발송", "(다우기술)" 제거 - 설정 저장 테스트가 실제 런타임 설정 파일을 오염시키던 결함 격리 --- docs/backend/api/README.md | 3 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 4 +- .../config/settings/defaults.json | 4 +- .../docs/api/README.md | 14 + .../docs/api/report.md | 79 ++ .../lang/en/messages.php | 7 + .../lang/ko/messages.php | 7 + .../sirsoft-message_bizppurio/plugin.json | 6 +- .../sirsoft-message_bizppurio/plugin.php | 25 +- .../layouts/plugin_settings.test.tsx | 62 +- .../resources/lang/en.json | 31 +- .../resources/lang/ko.json | 35 +- .../layouts/admin/plugin_settings.json | 695 +++++++++++------- .../ValidateBizppurioSettingsListener.php | 138 ++++ .../src/Services/BizppurioApiClient.php | 6 +- .../src/Services/BizppurioTokenService.php | 6 +- .../src/routes/api.php | 35 + .../Feature/Installation/InstallationTest.php | 5 +- ...izppurioLiveModeSettingsValidationTest.php | 228 ++++++ .../Settings/ReportUrlEndpointTest.php | 84 +++ .../tests/PluginTestCase.php | 50 +- .../Unit/Services/BizppurioApiClientTest.php | 10 +- .../Services/BizppurioTokenServiceTest.php | 31 +- .../tests/scenarios/plugin-settings.yaml | 59 +- .../tests/scenarios/sms-channel-dispatch.yaml | 2 +- 25 files changed, 1260 insertions(+), 366 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 97ac946e..ee08f795 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 9 · **엔드포인트 수**: 372 +- **확장 수**: 10 · **엔드포인트 수**: 373 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,6 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 1 / 1 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 67a4931a..67b9bc01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -8,7 +8,9 @@ ### Added -- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다. +- 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. - 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json index c77a63f4..d4650b55 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -4,7 +4,7 @@ "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false." }, "defaults": { - "environment": "dev", + "is_test_mode": true, "bizppurio_id": "", "password": "", "api_key": "", @@ -12,7 +12,7 @@ "sender_key": "" }, "frontend_schema": { - "environment": { "expose": false }, + "is_test_mode": { "expose": false }, "bizppurio_id": { "expose": false }, "password": { "expose": false, "sensitive": true }, "api_key": { "expose": false, "sensitive": true }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md new file mode 100644 index 00000000..4430a7a0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 1 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [report.md](report.md) | `report` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md new file mode 100644 index 00000000..9667ac02 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -0,0 +1,79 @@ +# Report API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Report 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/report-url + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.report.url` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/report-url HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| url | string | `http://g7-issue.eh.test/api/plugins/s…` | 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 콜백 URL(`/api/plugins/sirsoft-message_bizppurio/webhook`)의 사이트 도메인 기준 절대 주소. `config('app.url')` 을 신뢰 소스로 조합하므로 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 반환된다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "url": "https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | + + + +**설명** + +비즈뿌리오 발송 결과(리포트)를 수신할 콜백 URL을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 관리자 환경설정 화면(`admin/plugin_settings.json`)의 `report_url` data_source 가 이 값을 조회해 "리포트 수신 설정" 카드에 readonly 로 표시하고, 운영자가 그대로 복사해 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 URL PUSH 수신 주소로 등록합니다. + +URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합합니다 — 리버스 프록시 뒤 PHP-FPM 환경에서 요청 host 가 `localhost` 로 떨어질 수 있어, 운영자가 관리하는 설정값을 신뢰 소스로 삼아 항상 정식 도메인을 노출합니다. + +관리자 인증(`auth:sanctum`)과 `core.plugins.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다. + +※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index db51ab2f..e4b28302 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -52,6 +52,13 @@ return [ 'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.', ], + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => 'Bizppurio ID', + 'password_attribute' => 'Password', + 'sender_number_attribute' => 'Sender Number', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => 'Please set the Bizppurio ID and password first.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 6e6101d4..e6959a7a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -52,6 +52,13 @@ return [ 'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.', ], + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => '비즈뿌리오 아이디', + 'password_attribute' => '비밀번호', + 'sender_number_attribute' => '발신번호', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json index 1a752d1d..e9f35053 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -2,13 +2,13 @@ "identifier": "sirsoft-message_bizppurio", "vendor": "sirsoft", "name": { - "ko": "비즈뿌리오 메시징", + "ko": "비즈뿌리오 메시지 발송", "en": "Bizppurio Messaging" }, "version": "1.0.0", "description": { - "ko": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", - "en": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." + "ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", + "en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." }, "license": "MIT", "g7_version": ">=7.0.3", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 90e876ee..3979d8a1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -7,11 +7,12 @@ use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener; /** - * 비즈뿌리오 메시징 플러그인 + * 비즈뿌리오 메시지 발송 플러그인 * - * 비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송을 제공합니다. + * 비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송을 제공합니다. * 코어 알림 시스템의 채널로 문자·알림톡을 발송하고, 발송 결과를 webhook 으로 * 수신하여 이력에 기록합니다. */ @@ -146,11 +147,11 @@ class Plugin extends AbstractPlugin { return [ 'name' => [ - 'ko' => '비즈뿌리오 메시징', + 'ko' => '비즈뿌리오 메시지 발송', 'en' => 'Bizppurio Messaging', ], 'description' => [ - 'ko' => '비즈뿌리오 메시징 플러그인이 제공하는 권한', + 'ko' => '비즈뿌리오 메시지 발송 플러그인이 제공하는 권한', 'en' => 'Permissions provided by the Bizppurio Messaging plugin', ], 'categories' => [ @@ -203,14 +204,13 @@ class Plugin extends AbstractPlugin public function getSettingsSchema(): array { return [ - 'environment' => [ - 'type' => 'enum', - 'options' => ['dev', 'live'], - 'default' => 'dev', - 'label' => ['ko' => '연동 환경', 'en' => 'Environment'], + 'is_test_mode' => [ + 'type' => 'boolean', + 'default' => true, + 'label' => ['ko' => '검수 모드', 'en' => 'Test Mode'], 'hint' => [ - 'ko' => '검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.', - 'en' => 'Select the inspection (dev) or production environment. The sending API domain differs by environment.', + 'ko' => '검수 모드를 끄면 운영 환경으로 발송됩니다. 발송 API 도메인이 환경에 따라 분기됩니다.', + 'en' => 'Turn off test mode to send in the production environment. The sending API domain differs by environment.', ], 'required' => false, ], @@ -281,7 +281,7 @@ class Plugin extends AbstractPlugin public function getConfigValues(): array { return [ - 'environment' => 'dev', + 'is_test_mode' => true, 'bizppurio_id' => '', 'password' => '', 'api_key' => '', @@ -369,6 +369,7 @@ class Plugin extends AbstractPlugin return [ RegisterNotificationChannelsListener::class, GuestPhoneExtractListener::class, + ValidateBizppurioSettingsListener::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 5d5e5bea..f38145cc 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 @@ -55,19 +55,33 @@ describe('plugin_settings.json — 자동바인딩', () => { }); }); -describe('plugin_settings.json — 3 섹션', () => { +describe('plugin_settings.json — 섹션', () => { it.each([ + ['info_panel', '상단 안내 패널'], ['section_api', 'API 연동'], ['section_sending', '발송 설정'], - ['section_integration', '연동 안내'], + ['report_section', '리포트 수신 설정'], ])('%s 섹션이 존재한다', (id) => { expect(findById(root, id)).toBeTruthy(); }); + + it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => { + const card = findById(root, 'test_mode_card'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + expect(raw).toContain('"Toggle"'); + expect(raw).toContain('is_test_mode'); + }); + + it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => { + const warning = findById(root, 'live_mode_warning'); + expect(warning).toBeTruthy(); + expect((warning as { if?: string }).if).toContain('!_local.form.is_test_mode'); + }); }); describe('plugin_settings.json — 입력 필드 6종', () => { it.each([ - 'environment', 'bizppurio_id', 'password', 'api_key', @@ -91,21 +105,37 @@ describe('plugin_settings.json — 입력 필드 6종', () => { } }); - it('environment 는 dev/live 옵션 Select 이다', () => { - const select = findInputByName(root, 'environment'); - const options = (select?.children ?? []).map( - (c) => (c.props as { value?: string } | undefined)?.value - ); - expect(options).toEqual(['dev', 'live']); +}); + +describe('plugin_settings.json — 리포트 수신 설정', () => { + it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const reportUrl = sources.find((s) => s.id === 'report_url'); + expect(reportUrl).toBeTruthy(); + expect(reportUrl?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/report-url'); + }); + + it('리포트 섹션에 조회값(fallback 웹훅 경로) readonly 표시 + 복사 버튼이 있다', () => { + const section = findById(root, 'report_section'); + const raw = JSON.stringify(section); + expect(raw).toContain('report_url?.data?.url'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); + expect(raw).toContain('"readOnly":true'); + expect(raw).toContain('copyToClipboard'); }); }); -describe('plugin_settings.json — webhook 안내', () => { - it('연동 안내 섹션에 고정 webhook 경로가 readonly 로 표시된다', () => { - const section = findById(root, 'section_integration'); - const inputs = JSON.stringify(section); - expect(inputs).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); - expect(inputs).toContain('"readOnly":true'); +describe('plugin_settings.json — 필드 인라인 에러', () => { + it.each([ + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 필드에 인라인 에러 노드가 존재한다', (name) => { + const errorNode = findById(root, `field_${name}_error`); + expect(errorNode).toBeTruthy(); + expect((errorNode as { if?: string }).if).toContain(`_local.errors?.${name}`); }); }); @@ -124,7 +154,7 @@ describe('plugin_settings.json — 저장 버튼', () => { it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { const handlers = collectHandlers(layout); - const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView']; + const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard']; for (const h of handlers) { expect(allowed).toContain(h); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index ef90355f..e3f026ab 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -1,6 +1,6 @@ { "name": "Bizppurio Messaging", - "description": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.", "settings": { "title": "Bizppurio Messaging Settings", "description": "Manage Bizppurio integration credentials and sending options.", @@ -8,6 +8,16 @@ "saving": "Saving...", "save_success": "Settings saved.", "save_failed": "Failed to save settings.", + "info": { + "credentials": "Enter the integration credentials (ID, password, API key) used for sending and Kakao management, along with the sender information. Verify every value before turning off test mode and switching to production.", + "report": "To automatically receive delivery results (success/failure) and record them in the history, register the report endpoint below with Bizppurio." + }, + "test_mode": { + "label": "Test Mode", + "hint": "In test mode, messages are sent to the Bizppurio inspection (dev) domain. Turn it off to send through the production domain." + }, + "live_mode_warning_title": "Sending in production", + "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", "sections": { "api": { "title": "API Integration", @@ -16,19 +26,9 @@ "sending": { "title": "Sending Options", "description": "Sender information used for SMS and alimtalk delivery." - }, - "integration": { - "title": "Integration Guide", - "description": "The webhook address that receives delivery results." } }, "fields": { - "environment": { - "label": "Environment", - "hint": "Select the inspection (dev) or production environment. The sending API domain differs by environment.", - "option_dev": "Inspection (Dev)", - "option_live": "Production" - }, "bizppurio_id": { "label": "Bizppurio ID", "hint": "The Bizppurio account ID used for both sending and Kakao management." @@ -50,11 +50,12 @@ "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." } }, - "webhook": { - "label": "Delivery Result Address (webhook)", - "hint": "Register this address with the Bizppurio business team as the URL PUSH endpoint so delivery results are recorded automatically.", + "report": { + "section_title": "Report Endpoint", + "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).", + "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", "copy": "Copy", - "copied": "Copied." + "copied": "Report endpoint copied to clipboard." } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index e1d93a1f..4e585d3d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -1,13 +1,23 @@ { - "name": "비즈뿌리오 메시징", - "description": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다.", + "name": "비즈뿌리오 메시지 발송", + "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.", "settings": { - "title": "비즈뿌리오 메시징 설정", + "title": "비즈뿌리오 메시지 발송 설정", "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", "save": "저장", "saving": "저장 중...", "save_success": "설정이 저장되었습니다.", "save_failed": "설정 저장에 실패했습니다.", + "info": { + "credentials": "발송·카카오 관리에 사용하는 연동 정보(아이디·비밀번호·API 키)와 발신 정보를 입력합니다. 검수 모드를 끄고 운영으로 전환하기 전에 모든 값이 정확한지 확인하세요.", + "report": "발송 결과(성공/실패)를 자동으로 받아 이력에 기록하려면 아래 리포트 수신 주소를 비즈뿌리오에 등록해야 합니다." + }, + "test_mode": { + "label": "검수 모드", + "hint": "검수 모드에서는 비즈뿌리오 검수(개발) 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다." + }, + "live_mode_warning_title": "운영 환경으로 발송됩니다", + "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", "sections": { "api": { "title": "API 연동", @@ -16,19 +26,9 @@ "sending": { "title": "발송 설정", "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." - }, - "integration": { - "title": "연동 안내", - "description": "발송 결과를 수신하는 webhook 주소입니다." } }, "fields": { - "environment": { - "label": "연동 환경", - "hint": "검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.", - "option_dev": "검수(개발)", - "option_live": "운영" - }, "bizppurio_id": { "label": "비즈뿌리오 아이디", "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." @@ -50,11 +50,12 @@ "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." } }, - "webhook": { - "label": "발송 결과 수신 주소 (webhook)", - "hint": "비즈뿌리오 사업팀에 이 주소를 URL PUSH 수신 주소로 등록하면 발송 결과가 이력에 자동 기록됩니다.", + "report": { + "section_title": "리포트 수신 설정", + "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.", + "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", "copy": "복사", - "copied": "복사되었습니다." + "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." } } } 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 751cdb1e..bacd2c55 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 @@ -19,6 +19,15 @@ "auth_required": true, "initLocal": "form", "refetchOnMount": true + }, + { + "id": "report_url", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/report-url", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "reportInfo" } ], "slots": { @@ -30,7 +39,7 @@ "dataKey": "form", "trackChanges": true, "props": { - "className": "p-4 sm:p-6 lg:p-8 min-h-screen bg-gray-50 dark:bg-gray-900" + "className": "admin-page-content-responsive" }, "children": [ { @@ -44,6 +53,41 @@ "backLabel": "$t:admin.plugins.back_to_list" } }, + { + "id": "info_panel", + "type": "basic", + "name": "Div", + "props": { + "className": "alert-info" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-3" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "circle-info", + "size": "md", + "className": "text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "space-y-1 text-info-soft" }, + "children": [ + { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.credentials" }, + { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.report" } + ] + } + ] + } + ] + }, { "id": "validation_error", "type": "basic", @@ -54,15 +98,13 @@ "role": "alert", "aria-live": "assertive", "tabIndex": -1, - "className": "alert-danger scroll-mt-32" + "className": "mt-6 alert-danger scroll-mt-32" }, "children": [ { "type": "basic", "name": "Div", - "props": { - "className": "flex gap-3" - }, + "props": { "className": "flex gap-3" }, "children": [ { "type": "basic", @@ -80,18 +122,14 @@ { "type": "basic", "name": "H3", - "props": { - "className": "text-error-soft" - }, + "props": { "className": "text-error-soft" }, "text": "$t:admin.plugins.settings.validation_error" }, { "type": "basic", "name": "Ul", "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", - "props": { - "className": "mt-2 list-disc list-inside space-y-1" - }, + "props": { "className": "mt-2 list-disc list-inside space-y-1" }, "iteration": { "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", "item_var": "message" @@ -100,6 +138,7 @@ { "type": "basic", "name": "Li", + "props": { "className": "text-error-soft" }, "text": "{{message}}" } ] @@ -110,6 +149,100 @@ } ] }, + { + "id": "test_mode_card", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "p-6" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "is_test_mode" + } + } + ] + } + ] + } + ] + }, + { + "id": "live_mode_warning", + "type": "basic", + "name": "Div", + "if": "{{_local.form && !_local.form.is_test_mode}}", + "props": { + "className": "mt-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-3" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "size": "md", + "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "text-sm font-semibold text-warning-soft" }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_title" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "mt-1 text-warning-soft" }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_body" + } + ] + } + ] + } + ] + }, { "id": "section_api", "type": "basic", @@ -121,24 +254,18 @@ { "type": "basic", "name": "Div", - "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" - }, + "props": { "className": "panel-header-row" }, "children": [ { "type": "basic", "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, + "props": { "className": "section-heading-md" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, + "props": { "className": "form-hint mt-1" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" } ] @@ -146,89 +273,57 @@ { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-6" - }, + "props": { "className": "p-6 space-y-6" }, "children": [ { - "id": "field_environment", + "id": "field_bizppurio_id", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.label" - }, - { - "type": "basic", - "name": "Select", - "props": { - "name": "environment", - "className": "input mt-1" - }, + "name": "Div", + "props": { "className": "lg:col-span-4" }, "children": [ { "type": "basic", - "name": "Option", - "props": { - "value": "dev" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_dev" + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" }, { "type": "basic", - "name": "Option", - "props": { - "value": "live" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_live" + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" } ] }, { "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.hint" - } - ] - }, - { - "id": "field_bizppurio_id", - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" - }, - { - "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "bizppurio_id", - "autoComplete": "off", - "className": "input mt-1" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "bizppurio_id", + "autoComplete": "off", + "className": "{{_local.errors?.bizppurio_id ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_bizppurio_id_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.bizppurio_id}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.bizppurio_id?.[0] ?? ''}}" + } + ] } ] }, @@ -236,33 +331,52 @@ "id": "field_password", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "password", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "password", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.password ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_password_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.password}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.password?.[0] ?? ''}}" + } + ] } ] }, @@ -270,33 +384,52 @@ "id": "field_api_key", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "api_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "api_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.api_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_api_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.api_key}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.api_key?.[0] ?? ''}}" + } + ] } ] } @@ -315,24 +448,18 @@ { "type": "basic", "name": "Div", - "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" - }, + "props": { "className": "panel-header-row" }, "children": [ { "type": "basic", "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, + "props": { "className": "section-heading-md" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, + "props": { "className": "form-hint mt-1" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" } ] @@ -340,40 +467,57 @@ { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-6" - }, + "props": { "className": "p-6 space-y-6" }, "children": [ { "id": "field_sender_number", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "sender_number", - "autoComplete": "off", - "className": "input mt-1" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "sender_number", + "autoComplete": "off", + "className": "{{_local.errors?.sender_number ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_sender_number_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_number}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.sender_number?.[0] ?? ''}}" + } + ] } ] }, @@ -381,33 +525,52 @@ "id": "field_sender_key", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "sender_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "sender_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.sender_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_sender_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_key}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.sender_key?.[0] ?? ''}}" + } + ] } ] } @@ -416,79 +579,124 @@ ] }, { - "id": "section_integration", + "id": "report_section", "type": "basic", "name": "Div", "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800" }, "children": [ { "type": "basic", "name": "Div", "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + "className": "px-6 py-4 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg" }, "children": [ { "type": "basic", - "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.title" - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint" - }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.description" + "name": "Div", + "props": { "className": "flex-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "bell", + "size": "sm", + "className": "text-blue-600 dark:text-blue-400" + } + }, + { + "type": "basic", + "name": "H3", + "props": { "className": "text-base font-semibold text-blue-900 dark:text-blue-100" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.section_title" + } + ] } ] }, { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-2" - }, + "props": { "className": "p-6 space-y-4" }, "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.webhook.label" - }, { "type": "basic", "name": "Div", - "props": { - "className": "flex gap-2" - }, "children": [ { "type": "basic", - "name": "Input", - "props": { - "type": "text", - "readOnly": true, - "value": "/api/plugins/sirsoft-message_bizppurio/webhook", - "className": "input flex-1 font-mono text-sm bg-gray-50 dark:bg-gray-900" - } + "name": "P", + "props": { "className": "text-label mb-2" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.hint" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "readOnly": true, + "value": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}", + "className": "input-readonly flex-1 font-mono" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-body border border-gray-300 dark:border-gray-600 rounded-lg font-medium transition-colors whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "copyToClipboard", + "params": { + "text": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-message_bizppurio.settings.report.copied" + } + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "copy", "size": "sm" } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.report.copy" + } + ] + } + ] } ] }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, - "text": "$t:sirsoft-message_bizppurio.settings.webhook.hint" + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.note" } ] } @@ -498,9 +706,7 @@ "id": "footer_buttons", "type": "basic", "name": "Div", - "props": { - "className": "sticky bottom-0 z-10 flex justify-end items-center gap-3 mt-6 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700" - }, + "props": { "className": "sticky-footer-buttons" }, "children": [ { "id": "cancel_button", @@ -514,17 +720,11 @@ { "type": "click", "handler": "navigate", - "params": { - "path": "/admin/plugins" - } + "params": { "path": "/admin/plugins" } } ], "children": [ - { - "type": "basic", - "name": "Span", - "text": "$t:common.cancel" - } + { "type": "basic", "name": "Span", "text": "$t:common.cancel" } ] }, { @@ -543,11 +743,7 @@ "actions": [ { "handler": "setState", - "params": { - "target": "local", - "isSaving": true, - "errors": null - } + "params": { "target": "local", "isSaving": true, "errors": null } }, { "handler": "apiCall", @@ -560,24 +756,15 @@ "onSuccess": [ { "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "hasChanges": false - } + "params": { "target": "local", "isSaving": false, "hasChanges": false } }, { "handler": "toast", - "params": { - "type": "success", - "message": "$t:admin.plugins.settings.save_success" - } + "params": { "type": "success", "message": "$t:admin.plugins.settings.save_success" } }, { "handler": "refetchDataSource", - "params": { - "dataSourceId": "settings" - } + "params": { "dataSourceId": "settings" } } ], "onError": [ @@ -601,10 +788,7 @@ }, { "handler": "toast", - "params": { - "type": "error", - "message": "$t:admin.plugins.settings.save_error" - } + "params": { "type": "error", "message": "$t:admin.plugins.settings.save_error" } } ] } @@ -616,10 +800,7 @@ "type": "basic", "name": "Icon", "if": "{{_local.isSaving}}", - "props": { - "name": "spinner", - "className": "spinner-sm" - } + "props": { "name": "spinner", "className": "spinner-sm" } }, { "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php new file mode 100644 index 00000000..bd881706 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php @@ -0,0 +1,138 @@ + + */ + private const LIVE_REQUIRED_FIELDS = ['bizppurio_id', 'password', 'sender_number']; + + /** + * 구독 훅 메타데이터. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.update_rules' => [ + 'method' => 'addLiveModeRules', + 'priority' => 10, + 'type' => 'filter', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로 + * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅) + } + + /** + * 운영(live) 환경 진입 시 발송 자격증명에 required 규칙을 부여한다. + * + * @param array $rules 코어가 스키마로 생성한 검증 규칙 + * @param string $identifier 검증 대상 플러그인 식별자 + * @return array 조정된 검증 규칙 + */ + public function addLiveModeRules(array $rules, string $identifier): array + { + if ($identifier !== self::IDENTIFIER) { + return $rules; + } + + // 검수 모드(is_test_mode)가 명시적으로 false(운영) 일 때만 자격증명을 강제한다. + // 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가 + // "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로, + // 현재 입력 값 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력). + $isTestMode = filter_var( + // audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조 + request()->input('is_test_mode', true), + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE + ); + + if ($isTestMode !== false) { + return $rules; + } + + // 운영 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 등록한다. + // 요청 처리 시점에는 플러그인 lang 네임스페이스가 모두 준비돼 있어 폴백 없이 정확한 + // 로케일 라벨이 잡힌다. + $this->registerLiveCredentialAttributeLabels(); + + foreach (self::LIVE_REQUIRED_FIELDS as $field) { + $fieldRules = (array) ($rules[$field] ?? []); + // 코어가 부여한 nullable 을 제거하고 required 로 강제. + $fieldRules = array_values(array_filter( + $fieldRules, + static fn ($rule) => $rule !== 'nullable' + )); + if (! in_array('required', $fieldRules, true)) { + array_unshift($fieldRules, 'required'); + } + $rules[$field] = $fieldRules; + } + + return $rules; + } + + /** + * 발송 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다. + * + * 코어 UpdatePluginSettingsRequest 의 검증 에러 메시지에서 항목 이름이 영문 키(`bizppurio id`)로 + * 노출되지 않도록, 전역 validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를 + * 수정하지 않고 Laravel 의 attribute 해석 메커니즘만 활용한다. + */ + private function registerLiveCredentialAttributeLabels(): void + { + $locale = app()->getLocale(); + + // addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 Translator + // 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 회귀가 발생한다. + // 따라서 addLines 전에 validation 그룹을 먼저 로드시켜 캐시를 정상으로 채운 뒤 attributes 만 보탠다. + Lang::get('validation.required', [], $locale); + + Lang::addLines([ + 'validation.attributes.bizppurio_id' => __(self::IDENTIFIER.'::messages.settings.bizppurio_id_attribute', [], $locale), + 'validation.attributes.password' => __(self::IDENTIFIER.'::messages.settings.password_attribute', [], $locale), + 'validation.attributes.sender_number' => __(self::IDENTIFIER.'::messages.settings.sender_number_attribute', [], $locale), + ], $locale); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php index 4b915b1a..d8c6a50b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -125,9 +125,11 @@ class BizppurioApiClient */ private function baseUrl(): string { - $environment = (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'environment', 'dev'); + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); - return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php index 12f73b30..fa85e137 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -144,9 +144,11 @@ class BizppurioTokenService */ private function baseUrl(array $settings): string { - $environment = (string) ($settings['environment'] ?? 'dev'); + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) ($settings['is_test_mode'] ?? true); - return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php new file mode 100644 index 00000000..53aedba9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -0,0 +1,35 @@ +name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { + // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용) + // + // 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 주소를 사이트 도메인 + // 기준 절대 URL 로 내려준다. url() 헬퍼는 리버스 프록시 뒤에서 요청 host 가 + // localhost 로 떨어질 수 있어, 운영자가 관리하는 config('app.url') 을 신뢰 소스로 + // 삼아 절대화한다. 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 표시된다. + // + // ※ 실제 리포트 수신 처리(POST /webhook) 는 Phase 4 에서 구현한다. + Route::get('/report-url', function () { + $origin = rtrim((string) config('app.url', 'http://localhost'), '/'); + + return response()->json([ + 'success' => true, + 'data' => [ + 'url' => $origin.'/api/plugins/sirsoft-message_bizppurio/webhook', + ], + ]); + })->middleware('permission:admin,core.plugins.read') + ->name('report.url'); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index 448f2cd5..c3ffd2e3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -64,8 +64,9 @@ class InstallationTest extends PluginTestCase // 비크리덴셜 필드는 존재하되 sensitive 아님 $this->assertArrayHasKey('bizppurio_id', $schema); $this->assertArrayHasKey('sender_number', $schema); - $this->assertArrayHasKey('environment', $schema); - $this->assertSame('dev', $schema['environment']['default']); + $this->assertArrayHasKey('is_test_mode', $schema); + $this->assertSame('boolean', $schema['is_test_mode']['type']); + $this->assertTrue($schema['is_test_mode']['default']); } public function test_defaults_json의_크리덴셜은_expose_false다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php new file mode 100644 index 00000000..dc8a9408 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php @@ -0,0 +1,228 @@ +getSettingsSchema() 를 사용) + $manager = app(PluginManager::class); + $ref = new \ReflectionClass($manager); + $prop = $ref->getProperty('plugins'); + $prop->setAccessible(true); + $plugins = $prop->getValue($manager); + $plugins[self::IDENTIFIER] = new Plugin; + $prop->setValue($manager, $plugins); + + // 검증 규칙 필터 훅 등록 (실제 훅 체인 재현 — mock 없음). + $listener = new ValidateBizppurioSettingsListener; + HookManager::addFilter( + 'core.plugin_settings.update_rules', + [$listener, 'addLiveModeRules'], + 10 + ); + + $this->admin = $this->createAdminUser(); + $this->token = $this->admin->createToken('test-token')->plainTextToken; + } + + /** + * core.plugins.update 권한을 가진 admin 사용자 생성. + */ + private function createAdminUser(): User + { + $user = User::factory()->create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.update'], + ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_settings_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $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(); + } + + /** + * 인증 헤더가 적용된 PUT 요청 헬퍼. + * + * @param array $body + * @return TestResponse + */ + private function putSettings(array $body) + { + return $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->putJson('/api/admin/plugins/'.self::IDENTIFIER.'/settings', $body); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects live_mode_with_empty_credentials_returns_422_with_bizppurio_id_password_sender_number_errors + */ + public function test_live_mode_with_empty_credentials_is_rejected(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['bizppurio_id', 'password', 'sender_number']); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects api_key_and_sender_key_are_not_required_in_live_mode + */ + public function test_live_mode_does_not_require_api_key_or_sender_key(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + 'api_key' => '', + 'sender_key' => '', + ]); + + // api_key / sender_key 는 문자 발송 필수 조건이 아니므로 빈 값이어도 통과해야 한다. + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=empty,locale=ko + * + * @effects validation_error_messages_use_korean_field_labels_not_english_keys + */ + public function test_validation_error_messages_use_korean_field_labels(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + + $errors = $response->json('errors'); + // 코어 검증기 수정 없이 리스너의 Lang::addLines 로 주입한 한국어 라벨이 노출되어야 한다. + $this->assertStringContainsString('비즈뿌리오 아이디', $errors['bizppurio_id'][0]); + $this->assertStringNotContainsString('bizppurio id', $errors['bizppurio_id'][0]); + $this->assertStringContainsString('발신번호', $errors['sender_number'][0]); + } + + /** + * @scenario test_mode=off,credentials=filled,locale=ko + * + * @effects live_mode_with_filled_credentials_returns_200 + */ + public function test_live_mode_with_filled_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=on,credentials=empty,locale=ko + * + * @effects dev_mode_with_empty_credentials_returns_200 + */ + public function test_dev_mode_with_empty_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => true, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=filled + * + * @effects password_is_not_stored_as_plaintext_in_settings_file, password_decrypts_back_to_original_via_settings_service_get + */ + public function test_password_is_stored_encrypted(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'super-secret', + 'sender_number' => '021234567', + ])->assertStatus(200); + + // 저장 파일에 평문이 남지 않아야 한다 (sensitive 암호화). + // PluginTestCase 가 'plugins' 디스크를 테스트 전용 경로로 격리(#458)하므로 실제 + // storage_path 가 아니라 격리된 디스크를 통해 파일을 조회한다. + $relativePath = self::IDENTIFIER.'/settings/setting.json'; + $this->assertTrue( + \Illuminate\Support\Facades\Storage::disk('plugins')->exists($relativePath), + '설정 파일이 격리된 plugins 디스크에 저장되어야 한다.' + ); + $raw = \Illuminate\Support\Facades\Storage::disk('plugins')->get($relativePath); + $this->assertStringNotContainsString('super-secret', $raw); + + // 복호화 왕복은 원문과 일치해야 한다. + $value = app(PluginSettingsService::class)->get(self::IDENTIFIER, 'password'); + $this->assertSame('super-secret', $value); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php new file mode 100644 index 00000000..289ba568 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php @@ -0,0 +1,84 @@ +create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.read'], + ['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_report_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $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(); + } + + /** + * @scenario auth=admin,app_url=set + * + * @effects report_url_returns_absolute_url_built_from_app_url + */ + public function test_returns_absolute_report_url_based_on_app_url(): void + { + config(['app.url' => 'https://shop.example.com']); + + $admin = $this->createAdminUser(); + $token = $admin->createToken('test-token')->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ])->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $response->assertJsonPath('data.url', 'https://shop.example.com/api/plugins/sirsoft-message_bizppurio/webhook'); + } + + /** + * @scenario auth=guest + * + * @effects report_url_requires_authentication_returns_401 + */ + public function test_requires_authentication(): void + { + $response = $this->getJson(self::ENDPOINT); + + $response->assertStatus(401); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 3cf345c5..2406c567 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -4,6 +4,8 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; /** @@ -23,6 +25,9 @@ abstract class PluginTestCase extends TestCase */ private ?array $hookSnapshot = null; + /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */ + private ?string $isolatedPluginsRoot = null; + /** * 테스트 환경 설정 */ @@ -30,19 +35,62 @@ abstract class PluginTestCase extends TestCase { parent::setUp(); + $this->isolatePluginStorage(); $this->snapshotHookManager(); } /** - * tearDown 에 HookManager 상태 복원. + * tearDown 에 HookManager 상태 + plugins 스토리지 복원. */ protected function tearDown(): void { $this->restoreHookManager(); + $this->restorePluginStorage(); parent::tearDown(); } + /** + * 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다. + * + * 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크 + * (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면 + * 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다 + * (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를 + * 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못 + * 건드리게 한다. (회귀 배경: #458) + */ + private function isolatePluginStorage(): void + { + // Laravel 이 테스트용 쓰기 공간으로 보장하는 storage/framework/testing 하위를 사용한다. + // sys_get_temp_dir() 은 CI/컨테이너/open_basedir 제약 환경에서 위치가 다르거나 + // 쓰기 불가일 수 있어 프로젝트 내부의 격리 경로를 쓴다 (Laravel 규약 준수). + $this->isolatedPluginsRoot = storage_path( + 'framework/testing/plugin-storage-'.uniqid('', true) + ); + + File::ensureDirectoryExists($this->isolatedPluginsRoot); + + config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]); + + // 이미 resolve 된 'plugins' 디스크 인스턴스를 폐기해 새 root 로 재생성되게 한다. + Storage::forgetDisk('plugins'); + } + + /** + * 격리 임시 디렉토리를 제거한다 (테스트 간 잔여 파일 격리). + */ + private function restorePluginStorage(): void + { + if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) { + File::deleteDirectory($this->isolatedPluginsRoot); + } + + $this->isolatedPluginsRoot = null; + + Storage::forgetDisk('plugins'); + } + /** * HookManager static $hooks / $filters / $dispatching 를 스냅샷. */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php index a3aa286b..84397004 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -26,12 +26,12 @@ class BizppurioApiClientTest extends PluginTestCase 'content' => ['sms' => ['message' => 'hi']], ]; - private function makeSettings(string $environment = 'dev'): PluginSettingsService + private function makeSettings(bool $isTestMode = true): PluginSettingsService { $mock = Mockery::mock(PluginSettingsService::class); $mock->shouldReceive('get') - ->with(self::IDENTIFIER, 'environment', 'dev') - ->andReturn($environment); + ->with(self::IDENTIFIER, 'is_test_mode', true) + ->andReturn($isTestMode); return $mock; } @@ -88,13 +88,13 @@ class BizppurioApiClientTest extends PluginTestCase Http::assertSentCount(2); } - public function test_운영_환경은_live_도메인_호출(): void + public function test_운영_모드는_live_도메인_호출(): void { Http::fake([ 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200), ]); - $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings('live')); + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings(false)); $client->sendMessage($this->payload); Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com')); 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 15260192..e3c1dffc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -48,9 +48,9 @@ class BizppurioTokenServiceTest extends PluginTestCase } /** - * bizppurio_id/password/environment 를 반환하는 설정 mock. + * bizppurio_id/password/is_test_mode 를 반환하는 설정 mock. * - * @param array $settings + * @param array $settings */ private function makeSettings(array $settings): PluginSettingsService { @@ -69,7 +69,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->assertSame('TOKEN_A', $service->getToken()); @@ -90,7 +90,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->assertSame('TOKEN_A', $service->getToken()); @@ -99,7 +99,7 @@ class BizppurioTokenServiceTest extends PluginTestCase Http::assertSentCount(2); } - public function test_검수_환경은_dev_도메인을_호출한다(): void + public function test_검수_모드는_dev_도메인을_호출한다(): void { Http::fake([ 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), @@ -108,13 +108,30 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $service->getToken(); Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com')); } + public function test_운영_모드는_live_도메인을_호출한다(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => false]), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'api.bizppurio.com') + && ! str_contains($request->url(), 'dev-api.bizppurio.com')); + } + public function test_자격증명_미설정시_예외(): void { $store = []; @@ -134,7 +151,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->expectException(BizppurioApiException::class); 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 e76e0f21..047d746d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -1,49 +1,62 @@ # audit:allow test-scenario-coverage reason: | -# Phase 1 환경설정 화면은 입력·저장이 코어 /api/admin/plugins/{id}/settings 에 위임되는 -# 단순 자동바인딩 폼이라, axes cross product 의 런타임 효과(저장 성공/실패 왕복)는 코어 설정 -# 컨트롤러가 책임진다. 본 확장의 effects(3섹션 구조 / 6필드 / 크리덴셜 마스킹 / webhook 안내 / -# 저장 버튼 게이팅 / i18n 정합 / 권한·메뉴·알림 정의 / 설정 초기값 로드)는 PHPUnit 12건 -# (InstallationTest·EnumTest) + Vitest 22건(plugin_settings.test.tsx) 정적 검증으로 가드된다. -# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송 환경 필요) -# 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. 그때까지 -# 매니페스트는 명세로 유지한다. +# 환경설정 화면의 입력·저장은 코어 /api/admin/plugins/{id}/settings 에 위임되는 자동바인딩 +# 폼이라, axes cross product 의 UI 구조·검증·i18n effects 는 Vitest(plugin_settings.test.tsx) +# 30건 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소 +# 조회는 PHPUnit Feature(BizppurioLiveModeSettingsValidationTest·ReportUrlEndpointTest) 로 +# 실제 HTTP 왕복을 관찰한다. 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증 +# (PO 직접 수행, 인증·발송 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects +# docblock 완전 매핑을 추가한다. 그때까지 매니페스트는 명세로 유지한다. -feature: 비즈뿌리오 메시징 환경설정 (Phase 1) +feature: 비즈뿌리오 메시지 발송 환경설정 (Phase 1) description: | 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다. 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로 - 위임하며, 3 섹션(API 연동 / 발송 설정 / 연동 안내)으로 구성된다. + 위임하며, 상단 안내 패널 + 2 섹션(API 연동 / 발송 설정) + 리포트 수신 설정 카드로 구성된다. 핵심 동작: - - API 연동: 연동 환경(dev/live) + 비즈뿌리오 아이디 + 비밀번호 + API 키 + - 검수 모드: is_test_mode Toggle (독립 카드). 켜짐=검수(dev) 도메인, 꺼짐=운영(live) 도메인 + - API 연동: 비즈뿌리오 아이디 + 비밀번호 + API 키 - 발송 설정: 발신번호 + 알림톡 발신프로필 키 - - 연동 안내: 발송 결과 수신 webhook 주소(readonly 표시) + - 검수 모드를 끄면(운영) 경고 박스를 표시하고, 저장 시 발송 필수 자격증명 + (bizppurio_id/password/sender_number)을 required 로 강제(조건부 검증 필터 훅) + - 저장 실패 시 각 필드 하단에 인라인 에러 메시지 표시 + - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사 - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 - ※ 알림톡 채널 기본 body 시드(3영역)는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이므로 - Phase 6 으로 이관(plugin.php getNotificationDefinitions 주석 참조). + ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관. axes: credential_state: [empty, filled] - environment: [dev, live] + test_mode: [on, off] field_kind: [credential, plain, readonly] exclusions: - - { field_kind: readonly, credential_state: filled, reason: "webhook 주소는 고정 표시값 — 운영자 입력 대상 아님" } + - { field_kind: readonly, credential_state: filled, reason: "리포트 수신 주소는 서버 조회 표시값 — 운영자 입력 대상 아님" } effects: # 섹션 구조 - - three_sections_api_sending_integration_present + - info_panel_test_mode_api_sending_report_sections_present + - test_mode_card_has_is_test_mode_toggle + - live_mode_warning_shown_when_test_mode_off # 입력 필드 - - six_input_fields_bound_by_name + - five_input_fields_bound_by_name - credential_fields_masked_as_password - plain_fields_use_text_input - - environment_select_has_dev_live_options - # 연동 안내 - - webhook_address_shown_readonly + - inline_error_node_present_per_field + # 조건부 검증 (백엔드) + - test_mode_off_empty_credentials_return_422_for_bizppurio_id_password_sender_number + - test_mode_off_does_not_require_api_key_or_sender_key + - test_mode_on_empty_credentials_return_200 + - test_mode_off_filled_credentials_return_200 + - validation_error_messages_use_localized_field_labels + - test_mode_off_selects_live_domain_test_mode_on_selects_dev_domain + # 리포트 수신 설정 + - report_url_endpoint_returns_absolute_url_from_app_url + - report_url_endpoint_requires_authentication + - report_section_shows_readonly_url_with_copy_button # 저장 - save_button_disabled_without_changes - save_puts_to_core_plugin_settings_api @@ -55,10 +68,12 @@ effects: - activate_creates_hierarchical_admin_menus - deactivate_removes_plugin_menus - balance_low_notification_targets_admin - - settings_defaults_load_with_dev_environment + - settings_defaults_load_with_test_mode_on 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/Installation/InstallationTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml index 5b992043..623b6534 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml @@ -29,7 +29,7 @@ axes: channel: [sms, alimtalk] recipient: [member, guest] readiness_state: [ready, missing_credentials, missing_sender] - environment: [dev, live] + test_mode: [on, off] exclusions: - { channel: alimtalk, recipient: guest, reason: "알림톡 실발송은 Phase 6 — 이번 Phase 는 채널 등록/readiness 만" }