fix(payment): 나이스페이 SignData 방어와 비회원 영수증 조회 보강

This commit is contained in:
thisgun committed 2026-07-06 12:26:56 +09:00
1 parent 646efeb91c
commit d18aaab1fd
13 files changed
+464 -15

No files matched your search

@@ -8,6 +8,7 @@
### Security
- SignData 발급 요청에 주문번호+IP 단위 rate limit과 구매자 이메일/전화번호 대조를 추가해 공개 서명 엔드포인트의 주문번호/금액 프로빙 가능성을 줄였습니다.
- 콜백 URL 생성 시 요청 헤더 대신 `config('app.url')`을 기준으로 절대 URL을 만들도록 고정해 프록시 환경 오동작과 헤더 기반 리다이렉트 변조 위험을 줄였습니다.
- 카드 승인 콜백과 결제창 닫힘 통보 처리에 주문/결제 행잠금 후 재검증을 추가해 동시 콜백에서 마일리지, 재고, 결제 상태가 중복 반영되지 않도록 보강했습니다.
- 에스크로 배송등록 시 결제된 에스크로 거래의 저장 TID와만 바인딩하고, 결제 당시 MID와 테스트/운영 모드로 API 컨텍스트를 복원하도록 수정했습니다.
@@ -17,6 +18,7 @@
### Fixed
- 비회원 나이스페이먼츠 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
- 나이스페이먼츠 인증 단계 실패(`AuthResultCode != 0000`)는 실제 PG 승인 전이므로 주문/결제/주문옵션을 취소 상태로 바꾸지 않고 체크아웃 재시도가 가능하도록 수정했습니다.
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다.
- 나이스페이먼츠 간편결제 주문이 주문완료, 마이페이지 주문상세, 관리자 주문목록/상세에서 실제 간편결제 수단 대신 `신용카드`로만 표시되던 문제를 수정했습니다.
@@ -1,2 +1,2 @@
(function(){"use strict";function Oe(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Oe(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function Ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=Ye();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),C=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:C,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};C==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),$e=Number(w.total_tax_free_amount??0);(T>0||E>0||$e>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String($e))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:r},T),window.remLine truncated
(function(){"use strict";function Oe(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Oe(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function Ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=Ye();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),C=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:C,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};C==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),$e=Number(w.total_tax_free_amount??0);(T>0||E>0||$e>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String($e))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"Line truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'nicepay-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'nicepay-window-closed',\n };\n\n try {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(context.closeReportUrl, payload);\n return;\n }\n\n await fetch(resolveApiUrl(context.closeReportUrl), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive: true,\n });\n } catch (error) {\n console.warn('[sirsoft-pay_nicepayments] failed to report payment window close', error);\n }\n}\n","import { reportPaymentWindowClosed } from '../paymentCloseReport';\n\ninterface G7ApiResponse {\n data?: unknown;\n}\n\ninterface G7CoreApi {\n get: (url: string) => Promise<G7ApiResponse>;\n post?: (url: string, data?: Record<string, unknown>) => Promise<G7ApiResponse>;\n}\n\ninterface G7CoreToast {\n error: (message: string) => void;\n}\n\ninterface G7CoreStateApi {\n setLocal: (state: Record<string, unknown>) => void;\n}\n\ninterface G7Core {\n api: G7CoreApi;\n toast?: G7CoreToast;\n state?: G7CoreStateApi;\n}\n\ninterface TemplateLocalState {\n paymentMethod?: string;\n}\n\ninterface TemplateApp {\n globalState?: {\n _local?: TemplateLocalState;\n };\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n goods_cl?: string; // 휴대폰결제 상품 유형: '0'=디지털컨텐츠, '1'=실물\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n paymentMethod?: string;\n}\n\ninterface ClientConfig {\n mid: string;\n sdk_url: string;\n sign_data_url: string;\n close_report_url?: string;\n useEscrow?: boolean;\n}\n\ninterface SignDataResponse {\n ediDate: string;\n signData: string;\n mid: string;\n}\n\ninterface PaymentAction {\n params?: RequestPaymentParams;\n}\n\ndeclare global {\n interface Window {\n nicepaySubmit: () => void;\n nicepayClose: (resultCode: string, resultMsg: string) => void;\n goPay: (form: HTMLFormElement) => void;\n G7Core: G7Core;\n __templateApp?: TemplateApp;\n }\n}\n\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\nfunction createPaymentForm(action: string, fields: Record<string, string>): HTMLFormElement {\n const form = document.createElement('form');\n form.id = 'nicepayForm';\n form.method = 'post';\n form.action = action;\n // CharSet 필드와 일치 — 모바일은 form.acceptCharset 으로 charset 결정\n form.acceptCharset = 'utf-8';\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.creatLine truncated
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'nicepay-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'nicepay-window-closed',\n };\n\n try {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(context.closeReportUrl, payload);\n return;\n }\n\n await fetch(resolveApiUrl(context.closeReportUrl), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive: true,\n });\n } catch (error) {\n console.warn('[sirsoft-pay_nicepayments] failed to report payment window close', error);\n }\n}\n","import { reportPaymentWindowClosed } from '../paymentCloseReport';\n\ninterface G7ApiResponse {\n data?: unknown;\n}\n\ninterface G7CoreApi {\n get: (url: string) => Promise<G7ApiResponse>;\n post?: (url: string, data?: Record<string, unknown>) => Promise<G7ApiResponse>;\n}\n\ninterface G7CoreToast {\n error: (message: string) => void;\n}\n\ninterface G7CoreStateApi {\n setLocal: (state: Record<string, unknown>) => void;\n}\n\ninterface G7Core {\n api: G7CoreApi;\n toast?: G7CoreToast;\n state?: G7CoreStateApi;\n}\n\ninterface TemplateLocalState {\n paymentMethod?: string;\n}\n\ninterface TemplateApp {\n globalState?: {\n _local?: TemplateLocalState;\n };\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n goods_cl?: string; // 휴대폰결제 상품 유형: '0'=디지털컨텐츠, '1'=실물\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n paymentMethod?: string;\n}\n\ninterface ClientConfig {\n mid: string;\n sdk_url: string;\n sign_data_url: string;\n close_report_url?: string;\n useEscrow?: boolean;\n}\n\ninterface SignDataResponse {\n ediDate: string;\n signData: string;\n mid: string;\n}\n\ninterface PaymentAction {\n params?: RequestPaymentParams;\n}\n\ndeclare global {\n interface Window {\n nicepaySubmit: () => void;\n nicepayClose: (resultCode: string, resultMsg: string) => void;\n goPay: (form: HTMLFormElement) => void;\n G7Core: G7Core;\n __templateApp?: TemplateApp;\n }\n}\n\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\nfunction createPaymentForm(action: string, fields: Record<string, string>): HTMLFormElement {\n const form = document.createElement('form');\n form.id = 'nicepayForm';\n form.method = 'post';\n form.action = action;\n // CharSet 필드와 일치 — 모바일은 form.acceptCharset 으로 charset 결정\n form.acceptCharset = 'utf-8';\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.creatLine truncated
@@ -13,6 +13,8 @@ const PG_PAYMENT = {
order_number: 'ORD-001',
order_name: 'Test Order',
amount: 10000,
customer_email: 'buyer@example.com',
customer_phone: '01012345678',
};
describe('requestPaymentHandler', () => {
@@ -228,6 +230,21 @@ describe('requestPaymentHandler', () => {
expect(getSubmittedPayMethod()).toBe('CARD');
});
it('SignData 요청은 주문자 이메일과 전화번호를 함께 전송', async () => {
await requestPaymentHandler({
params: { pgPaymentData: PG_PAYMENT, paymentMethod: 'card' },
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
amt: PG_PAYMENT.amount,
moid: PG_PAYMENT.order_number,
buyer_email: PG_PAYMENT.customer_email,
buyer_phone: PG_PAYMENT.customer_phone,
});
});
it("paymentMethod 미명시 시 _local.paymentMethod 로 fallback", async () => {
(window as unknown as Record<string, unknown>).__templateApp = {
globalState: { _local: { paymentMethod: 'vbank' } },
@@ -208,10 +208,7 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
}
// 3. 서버에서 EdiDate + SignData 생성
// sign-data 엔드포인트는 'auth' 미들웨어가 걸려있어 Sanctum Bearer 토큰
// 또는 세션 쿠키 중 하나가 필요. SPA 모드에서 토큰만 있는 경우를 대비해
// localStorage 의 auth_token 을 Authorization 헤더로 명시 전달하고,
// credentials:include 로 세션 쿠키도 함께 전송 (둘 중 하나만 있어도 통과).
// 공개 엔드포인트지만 서버에서 주문번호, 결제 전 상태, 금액, 구매자 컨텍스트를 검증한다.
const signDataUrl = window.location.origin + config.sign_data_url;
const authToken = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
const signDataHeaders: Record<string, string> = {
@@ -226,7 +223,12 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
method: 'POST',
credentials: 'include',
headers: signDataHeaders,
body: JSON.stringify({ amt: pgPaymentData.amount, moid: pgPaymentData.order_number }),
body: JSON.stringify({
amt: pgPaymentData.amount,
moid: pgPaymentData.order_number,
buyer_email: pgPaymentData.customer_email ?? '',
buyer_phone: pgPaymentData.customer_phone ?? '',
}),
});
if (!signDataRes.ok) {
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Concerns;
use Illuminate\Cookie\CookieJar;
use Symfony\Component\HttpFoundation\Cookie;
trait IssuesReceiptCookie
{
public const RECEIPT_COOKIE_NAME = 'nicepayments_receipt_token';
private const RECEIPT_COOKIE_TTL_MINUTES = 5;
protected function queueReceiptCookie(string $orderNumber): void
{
$expiresTs = time() + (self::RECEIPT_COOKIE_TTL_MINUTES * 60);
$signature = $this->signReceiptToken($orderNumber, $expiresTs);
$tokenValue = $orderNumber.'|'.$expiresTs.'|'.$signature;
/** @var CookieJar $jar */
$jar = app(CookieJar::class);
$jar->queue(new Cookie(
name: self::RECEIPT_COOKIE_NAME,
value: $tokenValue,
expire: $expiresTs,
path: '/',
domain: null,
secure: request()->isSecure(),
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
protected function verifyReceiptCookie(?string $cookieValue, string $expectedOrderNumber): bool
{
if (! is_string($cookieValue) || $cookieValue === '') {
return false;
}
$parts = explode('|', $cookieValue);
if (count($parts) !== 3) {
return false;
}
[$orderNumber, $expiresTs, $signature] = $parts;
if ($orderNumber !== $expectedOrderNumber) {
return false;
}
if (! ctype_digit($expiresTs) || (int) $expiresTs < time()) {
return false;
}
if (! ctype_xdigit($signature) || strlen($signature) !== 64) {
return false;
}
return hash_equals($this->signReceiptToken($orderNumber, (int) $expiresTs), $signature);
}
private function signReceiptToken(string $orderNumber, int $expiresTs): string
{
return hash_hmac('sha256', $orderNumber.'|'.$expiresTs, (string) config('app.key', ''));
}
}
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
@@ -23,6 +24,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
@@ -41,6 +43,7 @@ use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
class PaymentCallbackController
{
use PreventsReplayCallback;
use IssuesReceiptCookie;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
@@ -199,6 +202,8 @@ class PaymentCallbackController
'ip' => $request->ip(),
]);
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
}
$this->cache->put($replayCacheKey, true, 60);
@@ -282,6 +287,7 @@ class PaymentCallbackController
$issueStatus = $this->recordVbankIssueWithLock($moid, $pgResponse, $resultCode, $payMethod, $isEscrow, $txTid);
if ($issueStatus === 'already_waiting') {
$this->logReplayDetected((string) ($pgResponse['TID'] ?? $txTid), $moid, 'authCallback (vbank issue)');
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
}
@@ -308,6 +314,7 @@ class PaymentCallbackController
if ($completionStatus === 'already_paid') {
$this->logReplayDetected($effectiveTid, $moid, 'authCallback (card)');
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
}
@@ -319,6 +326,7 @@ class PaymentCallbackController
]);
$this->apiService->sendNetCancel($netCancelUrl, $txTid, $authToken, $amt);
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
}
@@ -336,6 +344,8 @@ class PaymentCallbackController
}
}
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
} catch (PaymentAmountMismatchException $e) {
@@ -382,6 +392,7 @@ class PaymentCallbackController
$approvedTid = $pgResponse['TID'] ?? $txTid;
if ($this->wasAlreadyPaid((string) $approvedTid)) {
$this->logReplayDetected((string) $approvedTid, $moid, 'authCallback exception');
$this->queueReceiptCookie($moid);
return redirect($this->resolveSuccessUrl($moid));
}
@@ -584,6 +595,12 @@ class PaymentCallbackController
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 400);
}
$rateLimitKey = $this->signDataRateLimitKey($request, $moid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 429);
}
RateLimiter::hit($rateLimitKey, 60);
// 주문 금액 검증: 클라이언트가 임의 금액으로 SignData를 요청하는 조작 방지
$order = $this->orderService->findByOrderNumber($moid);
@@ -596,6 +613,15 @@ class PaymentCallbackController
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.order_not_found')], 422);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
Log::warning('NicePayments: SignData buyer verification failed', [
'moid' => $moid,
'ip' => $request->ip(),
]);
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 403);
}
$payment = $order->payment()->first();
if (! $payment || $payment->pg_provider !== 'nicepayments') {
@@ -667,6 +693,11 @@ class PaymentCallbackController
]);
}
private function signDataRateLimitKey(Request $request, string $moid): string
{
return 'sirsoft-pay_nicepayments:sign-data:'.sha1($request->ip().'|'.$moid);
}
/**
* 가상계좌 입금 통보 처리
*
@@ -7,14 +7,21 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
class UserReceiptController
{
use IssuesReceiptCookie;
use ResolvesEasyPayDisplay;
private const RECEIPT_BASE_URL = 'https://npg.nicepay.co.kr/issue/IssueLoader.do';
public function __construct(
private readonly GuestOrderAuthService $guestOrderAuthService,
) {}
/**
* 사용자 마이페이지 영수증 정보 조회
*
@@ -22,18 +29,36 @@ class UserReceiptController
* receipt_url 이 비어있으면 transaction_id 로 NicePay IssueLoader URL 을 동적 생성.
* 테스트 모드 결제는 is_test_mode=true 플래그로 표시되어 UI 에서 안내 가능.
*
* @param Request $request 인증된 사용자 요청
* @param Request $request 회원 또는 비회원 영수증 요청
* @param string $orderNumber 주문번호
* @return JsonResponse receipt_url / cash_receipt_url / is_test_mode 또는 404
*/
public function show(Request $request, string $orderNumber): JsonResponse
{
$user = $request->user();
$payment = DB::table('ecommerce_order_payments as p')
$query = DB::table('ecommerce_order_payments as p')
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('o.user_id', $user->id)
->where('p.pg_provider', 'nicepayments');
if ($user) {
$query->where('o.user_id', $user->id);
} else {
$order = $this->guestOrderAuthService->verifyToken($request->header('X-Guest-Order-Token'), $orderNumber);
if ($order) {
$query->whereNull('o.user_id')->where('o.id', $order->id);
} elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) {
$query->whereNull('o.user_id')
->where('o.id', function ($sub) use ($orderNumber) {
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
});
} else {
return response()->json(['error' => 'Not found'], 404);
}
}
$payment = $query
->select([
'p.transaction_id',
'p.receipt_url',
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Providers;
use App\Extension\BasePluginServiceProvider;
use Illuminate\Cookie\Middleware\EncryptCookies;
class PayNicepaymentsServiceProvider extends BasePluginServiceProvider
{
protected string $pluginIdentifier = 'sirsoft-pay_nicepayments';
public function boot(): void
{
parent::boot();
// PG callback 이 발급하는 영수증 쿠키는 HMAC 으로 자체 무결성 보장하므로
// EncryptCookies 대상에서 제외한다. 암호화 대상이면 callback 응답에서
// 직접 발급한 평문 쿠키가 다음 API 요청에서 복호화 실패로 폐기될 수 있다.
// 값 동기화: IssuesReceiptCookie::RECEIPT_COOKIE_NAME 과 동일해야 함.
EncryptCookies::except(['nicepayments_receipt_token']);
}
}
@@ -23,10 +23,9 @@ use Plugins\Sirsoft\PayNicepayments\Controllers\UserReceiptController;
Route::post('/payment/close-report', [PaymentCloseReportController::class, 'store'])
->name('payment.close-report');
Route::middleware(['auth:sanctum'])->group(function () {
Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show'])
->name('user.orders.receipt');
});
Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show'])
->middleware('optional.sanctum')
->name('user.orders.receipt');
Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () {
// 가상계좌 입금통보 URL 조회 (관리자 설정 페이지 표시용)
@@ -4,9 +4,11 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Mockery;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderAddressFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
@@ -205,6 +207,47 @@ class PaymentCallbackControllerTest extends PluginTestCase
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_amount'));
}
public function test_sign_data_rejects_buyer_mismatch(): void
{
$order = $this->createTestOrder(50000);
OrderAddressFactory::new()->forOrder($order)->shipping()->create([
'orderer_email' => 'buyer@example.com',
'orderer_phone' => '010-1234-5678',
]);
$this->mockPluginSettings();
$response = $this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
'amt' => 50000,
'moid' => $order->order_number,
'buyer_email' => 'attacker@example.com',
'buyer_phone' => '01099999999',
]);
$response->assertForbidden()
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request'));
}
public function test_sign_data_is_rate_limited_by_order_and_ip(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
RateLimiter::clear('sirsoft-pay_nicepayments:sign-data:'.sha1('127.0.0.1|'.$order->order_number));
for ($i = 0; $i < 20; $i++) {
$this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
'amt' => 50000,
'moid' => $order->order_number,
])->assertOk();
}
$this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
'amt' => 50000,
'moid' => $order->order_number,
])->assertStatus(429)
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request'));
}
public function test_sign_data_rejects_unchargeable_payment_currency(): void
{
$order = $this->createTestOrder(50000);
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
use App\Models\User;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
class UserReceiptControllerTest extends PluginTestCase
{
protected function setUp(): void
{
parent::setUp();
// 운영에서는 PayNicepaymentsServiceProvider::boot 가 동일하게 등록한다.
EncryptCookies::except(['nicepayments_receipt_token']);
}
public function test_guest_receipt_requires_valid_token(): void
{
$order = $this->createGuestOrder();
$this->createNicepaymentsPayment($order, 'NICE_TID_GUEST_001');
$this->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
->assertNotFound();
$this->withHeader('X-Guest-Order-Token', (time() + 1800).'|'.str_repeat('0', 64))
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
->assertNotFound();
}
public function test_guest_receipt_returns_receipt_url_with_valid_header_token(): void
{
app()->setLocale('ko');
$order = $this->createGuestOrder();
$this->createNicepaymentsPayment($order, 'NICE_TID_GUEST_002');
$response = $this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
->assertOk()
->assertJsonPath('receipt_url', 'https://npg.nicepay.co.kr/issue/IssueLoader.do?type=0&TID=NICE_TID_GUEST_002');
$this->assertContains($response->json('payment_method_label'), ['신용카드', 'Credit Card']);
}
public function test_guest_receipt_returns_receipt_url_with_valid_cookie(): void
{
$order = $this->createGuestOrder();
$this->createNicepaymentsPayment($order, 'NICE_TID_GUEST_003');
$this->withUnencryptedCookie('nicepayments_receipt_token', $this->issueReceiptCookie($order->order_number))
->withCredentials()
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
->assertOk()
->assertJsonPath('receipt_url', 'https://npg.nicepay.co.kr/issue/IssueLoader.do?type=0&TID=NICE_TID_GUEST_003');
}
public function test_guest_token_for_other_order_cannot_access_receipt(): void
{
$order1 = $this->createGuestOrder();
$order2 = $this->createGuestOrder();
$this->createNicepaymentsPayment($order1, 'NICE_TID_GUEST_004');
$this->createNicepaymentsPayment($order2, 'NICE_TID_GUEST_005');
$this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order1))
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order2->order_number}/receipt")
->assertNotFound();
}
public function test_authenticated_user_cannot_access_guest_order_receipt_via_token(): void
{
$user = User::factory()->create();
$order = $this->createGuestOrder();
$this->createNicepaymentsPayment($order, 'NICE_TID_GUEST_006');
$this->actingAs($user)
->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
->assertNotFound();
}
private function createGuestOrder()
{
return OrderFactory::new()->create([
'user_id' => null,
'guest_lookup_password_hash' => Hash::make('test1234'),
'order_number' => 'ORD-NICE-GUEST-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'total_amount' => 1000,
'total_due_amount' => 0,
'total_paid_amount' => 1000,
'paid_at' => now(),
]);
}
private function createNicepaymentsPayment($order, string $transactionId): void
{
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nicepayments',
'transaction_id' => $transactionId,
'receipt_url' => null,
'paid_amount_local' => 1000,
'paid_at' => now(),
'payment_meta' => ['is_test_mode' => true],
]);
}
private function issueGuestToken($order): string
{
$svc = app(GuestOrderAuthService::class);
$rc = new \ReflectionClass($svc);
$signMethod = $rc->getMethod('sign');
$signMethod->setAccessible(true);
$suffixMethod = $rc->getMethod('passwordHashSuffix');
$suffixMethod->setAccessible(true);
$expiresTs = time() + 1800;
$suffix = $suffixMethod->invoke($svc, $order);
$sig = $signMethod->invoke($svc, $order->order_number, (int) $order->id, $expiresTs, $suffix);
return $expiresTs.'|'.$sig;
}
private function issueReceiptCookie(string $orderNumber): string
{
$expiresTs = time() + 300;
$payload = $orderNumber.'|'.$expiresTs;
$signature = hash_hmac('sha256', $payload, (string) config('app.key', ''));
return $payload.'|'.$signature;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Concerns;
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
/**
* 영수증 cookie HMAC 검증 로직 단위 테스트.
*
* HTTP 통합(callback → Set-Cookie → 후속 호출에서 cookie 자동 첨부) 검증은
* Playwright 시나리오로 다룬다 — Laravel 의 EncryptCookies 미들웨어와 PHPUnit
* HTTP 테스트 환경의 cookie 처리 흐름이 production 과 일부 다르기 때문.
*/
class IssuesReceiptCookieTest extends PluginTestCase
{
private object $subject;
protected function setUp(): void
{
parent::setUp();
// IssuesReceiptCookie 는 trait — 익명 클래스로 인스턴스화
$this->subject = new class {
use IssuesReceiptCookie {
verifyReceiptCookie as public;
}
};
}
public function test_verify_succeeds_with_fresh_token_matching_order_number(): void
{
$orderNumber = 'ORD-RECEIPT-12345';
$cookieValue = $this->buildToken($orderNumber, time() + 300);
$this->assertTrue($this->subject->verifyReceiptCookie($cookieValue, $orderNumber));
}
public function test_verify_fails_when_cookie_missing(): void
{
$this->assertFalse($this->subject->verifyReceiptCookie(null, 'ORD-RECEIPT-99999'));
$this->assertFalse($this->subject->verifyReceiptCookie('', 'ORD-RECEIPT-99999'));
}
public function test_verify_fails_when_token_format_invalid(): void
{
// 3-파트 미충족
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|123', 'ORD-X'));
// 만료시각이 숫자 아님
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|abc|'.str_repeat('0', 64), 'ORD-X'));
// 서명이 hex 아님
$this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|'.(time() + 300).'|invalid_sig', 'ORD-X'));
}
public function test_verify_fails_when_token_expired(): void
{
$orderNumber = 'ORD-RECEIPT-EXPIRED';
$expiredCookie = $this->buildToken($orderNumber, time() - 60);
$this->assertFalse($this->subject->verifyReceiptCookie($expiredCookie, $orderNumber));
}
public function test_verify_fails_when_order_number_mismatched(): void
{
$tokenFor = 'ORD-RECEIPT-AAA';
$accessing = 'ORD-RECEIPT-BBB';
$cookieValue = $this->buildToken($tokenFor, time() + 300);
// cookie 의 payload 가 다른 주문번호이므로 reject
$this->assertFalse($this->subject->verifyReceiptCookie($cookieValue, $accessing));
}
public function test_verify_fails_when_signature_tampered(): void
{
$orderNumber = 'ORD-RECEIPT-TAMPER';
$expiresTs = time() + 300;
// 정상 서명 대신 0으로 가득 찬 위조 서명
$tampered = $orderNumber.'|'.$expiresTs.'|'.str_repeat('0', 64);
$this->assertFalse($this->subject->verifyReceiptCookie($tampered, $orderNumber));
}
private function buildToken(string $orderNumber, int $expiresTs): string
{
$payload = $orderNumber.'|'.$expiresTs;
$signature = hash_hmac('sha256', $payload, (string) config('app.key', ''));
return $orderNumber.'|'.$expiresTs.'|'.$signature;
}
}