diff --git a/lib/shop.cartvalidate.lib.php b/lib/shop.cartvalidate.lib.php index a988b1c0e..cf45133e1 100644 --- a/lib/shop.cartvalidate.lib.php +++ b/lib/shop.cartvalidate.lib.php @@ -135,6 +135,47 @@ function shop_validate_cart_request($post, $multi = false) return array('error' => $products ? '' : $error, 'products' => $products); } +// 기존 행과 합친 결과도 삭제/UPDATE 전에 모든 상품에 대해 확인한다. +// 동시 요청과 SQL 실패의 원자성까지 보장하는 잠금/트랜잭션은 아니다. +function shop_validate_cart_merge($cart_id, $products, $direct = false, $replace = false) +{ + global $g5; + $cart_id = sql_escape_string($cart_id); + foreach ($products as $it_id => $product) { + $id = sql_escape_string($it_id); + $rows = array(); + if (!$replace) { + $exclude = $direct ? ' and ct_direct <> 1 ' : ''; + $result = sql_query(" select * from {$g5['g5_shop_cart_table']} where od_id = '$cart_id' and it_id = '$id' and ct_status = '쇼핑' $exclude order by ct_id asc "); + while ($row = sql_fetch_array($result)) + $rows[] = $row; + } + $rows = array_merge($rows, $product['rows']); + list($item, $options) = shop_cart_option_data($it_id); + $validated = shop_validate_cart_rows($item, $options, $rows, true); + if ($validated['error'] !== '') + return $validated['error']; + + // 바로구매에서는 다른 장바구니가 선택해 둔 수량도 선삭제 전에 검사한다. + if ($direct) { + $quantities = array(); + foreach ($rows as $row) { + $key = $row['io_type'].':'.$row['io_id']; + $quantities[$key] = isset($quantities[$key]) ? $quantities[$key] + $row['ct_qty'] : $row['ct_qty']; + } + foreach ($product['rows'] as $row) { + $io_id = sql_escape_string($row['io_id']); + $type = (int) $row['io_type']; + $reserved = sql_fetch(" select SUM(ct_qty) as cnt from {$g5['g5_shop_cart_table']} where od_id <> '$cart_id' and it_id = '$id' and io_id = '$io_id' and io_type = '$type' and ct_stock_use = 0 and ct_status = '쇼핑' and ct_select = '1' "); + $stock = $row['io_id'] === '' ? get_it_stock_qty($it_id) : get_option_stock_qty($it_id, $row['io_id'], $type); + if ($quantities[$type.':'.$row['io_id']] + (int) $reserved['cnt'] > $stock) + return '상품 또는 옵션의 재고수량이 부족합니다.'; + } + } + } + return ''; +} + // 주문 준비/확정 시에는 선택된 행만 검증하여 보조옵션 단독 선택과 과거 조작 행도 차단한다. function shop_validate_order_cart($cart_id) { diff --git a/mobile/shop/inicis/cart_validation_cancel.php b/mobile/shop/inicis/cart_validation_cancel.php new file mode 100644 index 000000000..6155ec45a --- /dev/null +++ b/mobile/shop/inicis/cart_validation_cancel.php @@ -0,0 +1,48 @@ + $cart_cancel_method, + 'tid' => $cart_cancel_tid, + 'msg' => '장바구니 검증 실패', + 'audit' => false +)) : ''; +$cart_cancel_result = json_decode($cart_cancel_response, true); +$cart_cancel_success = is_array($cart_cancel_result) && isset($cart_cancel_result['resultCode']) && $cart_cancel_result['resultCode'] === '00'; +$cart_cancel_status = $cart_cancel_success ? 'cancel' : 'cancel_failed'; + +// 성공을 확인하기 전에 취소 완료로 기록하지 않는다. 실패 거래는 관리자 확인용으로 남긴다. +$cart_cancel_oid = sql_escape_string(get_session('ss_order_id')); +$cart_cancel_log_tid = sql_escape_string($cart_cancel_tid); +$cart_cancel_log_message = $cart_cancel_success ? '장바구니 검증 실패: 승인 취소 완료' : '장바구니 검증 실패: 승인 취소 결과 확인 필요'; +$cart_cancel_time = preg_replace('/[^0-9]/', '', G5_TIME_YMDHIS); +$cart_cancel_mid = sql_escape_string($default['de_inicis_mid']); +$cart_cancel_log_amount = (int) $cart_cancel_amount; +$cart_cancel_old_log = sql_fetch(" select oid from {$g5['g5_shop_inicis_log_table']} where oid = '$cart_cancel_oid' "); +if (!empty($cart_cancel_old_log['oid'])) { + sql_query(" update {$g5['g5_shop_inicis_log_table']} set P_STATUS = '$cart_cancel_status', P_RMESG1 = '$cart_cancel_log_message', P_AUTH_DT = '$cart_cancel_time' where oid = '$cart_cancel_oid' and P_TID = '$cart_cancel_log_tid' ", false); +} else { + sql_query(" insert into {$g5['g5_shop_inicis_log_table']} (oid, P_TID, P_MID, P_AMT, P_STATUS, P_RMESG1, P_AUTH_DT, post_data) values ('$cart_cancel_oid', '$cart_cancel_log_tid', '$cart_cancel_mid', '$cart_cancel_log_amount', '$cart_cancel_status', '$cart_cancel_log_message', '$cart_cancel_time', '') ", false); +} +error_log('KVE-2026-2345: inicis '.$cart_cancel_status.' oid='.get_session('ss_order_id').' tid='.$cart_cancel_tid); +if (function_exists('add_order_post_log')) add_order_post_log($cart_cancel_log_message); +if ($cart_cancel_success) { + set_session('P_TID', ''); + set_session('P_AMT', ''); + set_session('P_HASH', ''); + $cart_validation_error .= ' 결제 승인은 취소되었습니다.'; +} else { + $cart_validation_error .= ' 결제 취소 결과를 확인하지 못했습니다. 재결제 전에 상점에 문의해 주십시오.'; +} diff --git a/mobile/shop/inicis/pay_approval.php b/mobile/shop/inicis/pay_approval.php index 3dd76e7de..9eb4fc058 100644 --- a/mobile/shop/inicis/pay_approval.php +++ b/mobile/shop/inicis/pay_approval.php @@ -40,6 +40,11 @@ if(isset($data['pp_id']) && $data['pp_id']) { if (get_cart_count($tmp_cart_id) == 0)// 장바구니에 담기 alert('세션을 잃거나 다른 브라우저에서 데이터가 변경된 경우입니다. 장바구니 상태를 확인후에 다시 시도해 주세요.', G5_SHOP_URL.'/cart.php'); + // KVE-2026-2345: PG 승인 요청 전에 상품·옵션 구성과 금액을 확인한다. + $cart_validation_error = shop_validate_order_cart($tmp_cart_id); + if ($cart_validation_error !== '') + alert($cart_validation_error, G5_SHOP_URL.'/cart.php'); + $error = ""; // 장바구니 상품 재고 검사 $sql = " select it_id, diff --git a/mobile/shop/orderformupdate.php b/mobile/shop/orderformupdate.php index 34a31fbd0..77ebc65c6 100644 --- a/mobile/shop/orderformupdate.php +++ b/mobile/shop/orderformupdate.php @@ -66,14 +66,13 @@ if (get_session('ss_direct')) else $tmp_cart_id = get_session('ss_cart_id'); -if (get_cart_count($tmp_cart_id) == 0) { // 장바구니에 담기 - if(function_exists('add_order_post_log')) add_order_post_log('장바구니가 비어 있습니다.'); - alert('장바구니가 비어 있습니다.\\n\\n이미 주문하셨거나 장바구니에 담긴 상품이 없는 경우입니다.', G5_SHOP_URL.'/cart.php'); -} - -// KVE-2026-2345: 과거 장바구니의 옵션 종류와 가격도 주문 전에 다시 검증한다. -$cart_validation_error = shop_validate_order_cart($tmp_cart_id); +// KVE-2026-2345: 승인 이후 장바구니가 삭제된 경우도 취소 처리로 연결한다. +$cart_validation_error = get_cart_count($tmp_cart_id) == 0 + ? '장바구니가 비어 있습니다.' + : shop_validate_order_cart($tmp_cart_id); if ($cart_validation_error !== '') { + if ($default['de_pg_service'] === 'inicis') + include G5_MSHOP_PATH.'/inicis/cart_validation_cancel.php'; alert($cart_validation_error, G5_SHOP_URL.'/cart.php'); } diff --git a/shop/ajax.action.php b/shop/ajax.action.php index d637bfa17..70384b269 100644 --- a/shop/ajax.action.php +++ b/shop/ajax.action.php @@ -82,6 +82,10 @@ switch ($action) { if ($cart_validation['error'] !== '') die(json_encode(array('error' => $cart_validation['error']))); + $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct)); + if ($cart_merge_error !== '') + die(json_encode(array('error' => $cart_merge_error))); + $ct_count = 0; for($i=0; $i<$count; $i++) { $it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : ''; diff --git a/shop/cartupdate.php b/shop/cartupdate.php index 465bea2ff..23a8d17a6 100644 --- a/shop/cartupdate.php +++ b/shop/cartupdate.php @@ -150,6 +150,18 @@ else // 장바구니에 담기 if ($cart_validation['error'] !== '') alert($cart_validation['error']); + $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct), $act === 'optionmod'); + if ($cart_merge_error !== '') + alert($cart_merge_error); + + // 구매 자격 오류도 바로구매 삭제와 다중 상품 변경 전에 확인한다. + if (!$is_admin) { + foreach ($cart_validation['products'] as $cart_it_id => $cart_product) { + $msg = shop_member_cert_check($cart_it_id, 'item'); + if ($msg) alert($msg, G5_SHOP_URL); + } + } + $ct_count = 0; $post_chk_it_id = (isset($_POST['chk_it_id']) && is_array($_POST['chk_it_id'])) ? $_POST['chk_it_id'] : array(); $post_io_ids = (isset($_POST['io_id']) && is_array($_POST['io_id'])) ? $_POST['io_id'] : array(); @@ -180,13 +192,6 @@ else // 장바구니에 담기 alert('수량은 1 이상 입력해 주십시오.'); } - // 본인인증, 성인인증체크 - if(!$is_admin) { - $msg = shop_member_cert_check($it_id, 'item'); - if($msg) - alert($msg, G5_SHOP_URL); - } - // 상품정보 $it = $cart_validation['products'][$it_id]['item']; if(!$it['it_id']) diff --git a/tests/README.cart-validation.md b/tests/README.cart-validation.md index de3b073c4..52299e32a 100644 --- a/tests/README.cart-validation.md +++ b/tests/README.cart-validation.md @@ -28,4 +28,18 @@ PC·모바일 주문 처리 파일은 추가한 장바구니 검증 경계까지 두 AJAX 경로와 일반 추가 경로는 요청 전체의 상품·옵션 구성을 검증한 후 기존 변경 로직으로 진행한다. 옵션 종류·가격은 검증한 서버 데이터에서 가져온다. 주문 준비 및 PC·모바일 주문 제출은 선택된 기존 장바구니 행도 검증하여 과거 조작 행과 가격 불일치를 거부한다. 정상 무료상품은 허용한다. -기존 테이블 엔진은 변경하지 않으며, 장바구니·주문 전체의 트랜잭션이나 외부 PG 복구 흐름을 새로 도입하지 않는다. 따라서 사전 검증 이후의 DB 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다. +기존 테이블 엔진과 장바구니·주문 전체의 트랜잭션은 변경하지 않는다. 따라서 사전 검증 이후의 DB 오류·동시 변경까지 모든 처리가 원자적이라고 보장하는 변경은 아니다. + +## 후속 검토 회귀 검증 + +```bash +python3 tests/shop_cart_followup.py /path/to/php -n -d extension=pdo_sqlite +``` + +PHP의 확장 경로 설정이 필요한 환경에서는 `-d extension_dir=/path/to/ext`도 전달한다. Python 표준 라이브러리와 PHP PDO SQLite를 사용하며, 테스트 전용 임시 디렉터리와 SQLite DB는 종료 시 정리한다. `-n`은 실제 cURL 확장을 로드하지 않고 모의 승인 응답을 사용하기 위한 설정이다. + +25개 검증에서 세 추가 경로의 기존 가격 불일치·누적 재고·최대 수량·다중 상품 실패 시 행 보존과 정상 병합을 확인한다. 일반 추가의 바로구매 실패 시 선삭제 방지, 정상 옵션 교체, 뒤쪽 상품의 구매 자격 거부 시 행 보존도 확인한다. 실제 모바일 이니시스 승인 파일에서 모의 승인 응답을 거쳐 실제 모바일 주문 검증·취소 연결 파일을 실행하여 승인 전 차단, 승인 도중 가격 변경·장바구니 삭제, 취소 성공·실패, 기존 로그 갱신, 정상 검증 통과 및 위조 해시에 대한 취소 미실행을 확인한다. + +기존 행 병합 결과는 모든 상품에 대해 변경 전에 검사한다. 모바일 이니시스는 승인 전에 장바구니를 검사하고, 승인 후 주문 검증 실패 시 서버 세션과 해시로 확인한 거래번호를 기존 INIAPI 취소 함수에 전달한다. 취소 응답이 성공일 때만 취소 완료를 안내하며, 실패는 로그와 확인 필요 안내를 남긴다. + +이 검증의 SQL은 SQLite에서 실행하므로 MyISAM·InnoDB 테스트를 대체하지 않는다. PG 승인 응답과 취소 API는 모의 구현이며 실제 PG의 취소 성공이나 전체 주문 저장 완료를 검증한 결과가 아니다. 승인 이후 검사와 저장 사이의 동시 변경, 통신 실패 후 운영 복구, 실제 PG 통합 검증은 별도로 필요하다. diff --git a/tests/shop_cart_followup.py b/tests/shop_cart_followup.py new file mode 100644 index 000000000..64d769394 --- /dev/null +++ b/tests/shop_cart_followup.py @@ -0,0 +1,180 @@ +"""실제 처리 파일의 순차 SQL/승인 경로 회귀검증. 외부 PG는 모의한다. +실행: python tests/shop_cart_followup.py /path/to/php +PHP pdo_sqlite가 필요하며 임시 SQLite DB만 생성한다. +""" +import ast, json, re, sqlite3, subprocess, sys, tempfile, atexit +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +temporary = tempfile.TemporaryDirectory(prefix='cart-followup-') +atexit.register(temporary.cleanup) +OUT = Path(temporary.name) +PHP = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path('php') +PHP_ARGS = sys.argv[2:] +tree = ast.parse((ROOT / 'tests/shop_cart_http.py').read_text(encoding='utf-8')) +bootstrap = next(n.value.value for n in ast.walk(tree) if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'bootstrap' for t in n.targets)) +start = bootstrap.index("$link = new mysqli") +end = bootstrap.index('function safe_replace_regex', start) +bootstrap = bootstrap[:start] + '''$link = new PDO('sqlite:'.__DIR__.'/fixture.sqlite'); +$link->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +function sql_query($sql, $error=true) { global $link; return $link->query($sql); } +function sql_fetch_array($result) { return $result->fetch(PDO::FETCH_ASSOC); } +function sql_fetch($sql) { return sql_fetch_array(sql_query($sql)) ?: array(); } +function sql_escape_string($s) { return str_replace("'", "''", (string)$s); } +function sql_real_escape_string($s) { return sql_escape_string($s); } +''' + bootstrap[end:] +bootstrap = bootstrap.replace('require TEST_VALIDATOR;', 'require '+json.dumps(str(ROOT / 'lib/shop.cartvalidate.lib.php'))+';') +bootstrap += ''' +$_SESSION['ss_cart_id'] = '123456789'; +define('G5_MSHOP_PATH', __DIR__); +define('G5_SHOP_PATH', __DIR__); +define('G5_HTTPS_MSHOP_URL', '/mobile/shop'); +$g5['g5_shop_inicis_log_table'] = 'g5_shop_inicis_log'; +$default['de_inicis_mid'] = 'test-mid'; +function get_shop_order_data($id) { return array(); } +if (getenv('CASE') === 'approved') { + $default['de_pg_service'] = 'inicis'; + $od_settle_case = '신용카드'; + $_POST['P_HASH'] = 'simulated-verified-approval'; + $_SESSION['P_TID'] = 'simulated-approved-tid'; + $_SESSION['P_AMT'] = '50000'; + $_SESSION['P_HASH'] = $_POST['P_HASH']; +} else { + $_POST = array('action'=>'cart_update', 'it_id'=>array('item'), + 'io_id'=>array('item'=>array('', 'wrap')), 'io_type'=>array('item'=>array('0','1')), + 'ct_qty'=>array('item'=>array('1','1'))); + $_REQUEST = $_POST; +} +''' +bootstrap = bootstrap.replace("getenv('CASE') === 'approved'", "in_array(getenv('CASE'), array('approved', 'callback'))") +bootstrap += ''' +if (getenv('SCENARIO') === 'direct') $sw_direct = $_POST['sw_direct'] = $_REQUEST['sw_direct'] = 1; +if (getenv('SCENARIO') === 'replace') $_POST['act'] = 'optionmod'; +if (in_array(getenv('SCENARIO'), array('multi','cert'))) { + $_POST['it_id'][] = 'other'; + $_POST['io_id']['other'] = array(''); + $_POST['io_type']['other'] = array('0'); + $_POST['ct_qty']['other'] = array('1'); +} +if (getenv('SCENARIO') === 'cert') $is_admin = false; +function shop_member_cert_check($id,$type) { return $id === 'other' ? '구매 자격 확인 필요' : ''; } +if (getenv('CASE') === 'callback') { + $_SESSION['ss_order_id'] = '1001'; + $g5['g5_shop_order_data_table'] = 'order_data'; + $default['de_inicis_mid'] = 'test-mid'; + $_REQUEST = array('P_NOTI'=>'test-order','P_REQ_URL'=>'https://example.invalid/approval','P_STATUS'=>'00','P_TID'=>'test-tid'); + function is_inicis_url_return($s) { return $s; } + function iconv_utf8($s) { return $s; } + function get_search_string($s) { return $s; } + foreach (array('CURLOPT_PORT','CURLOPT_URL','CURLOPT_POST','CURLOPT_POSTFIELDS','CURLOPT_RETURNTRANSFER') as $i=>$c) define($c,$i); + function curl_init() { return true; } + function curl_setopt($h,$k,$v) { return true; } + function curl_exec($h) { + global $link; + $GLOBALS['review_pg_approved'] = true; + if (getenv('SCENARIO') === 'deleted') + $link->exec('DELETE FROM g5_shop_cart'); + else if (getenv('SCENARIO') !== 'unchanged') + $link->exec('UPDATE g5_shop_item SET it_price=50000'); + return 'P_STATUS=00&P_TID=test-tid&P_MID=test-mid&P_AMT=40000&P_TYPE=CARD'; + } + $link->exec('DROP TABLE IF EXISTS order_data'); + $link->exec('CREATE TABLE order_data (od_id TEXT, dt_data TEXT)'); + $stmt = $link->prepare('INSERT INTO order_data VALUES (?,?)'); + $stmt->execute(array('test-order',base64_encode(serialize(array('od_settle_case'=>'신용카드'))))); +} +''' +bootstrap = bootstrap.replace("array('error'=>$message)", "array('error'=>$message, 'simulated_pg_approved'=>!empty($GLOBALS['review_pg_approved']), 'canceled_tid'=>isset($GLOBALS['canceled_tid']) ? $GLOBALS['canceled_tid'] : '', 'session_tid'=>get_session('P_TID'))") +(OUT/'bootstrap.php').write_text(bootstrap, encoding='utf-8') +(OUT/'mailer.lib.php').write_text('getenv('SCENARIO') === 'cancel_fail' ? '99' : '00')); +} +''', encoding='utf-8') +for name, source in [('ajax','shop/ajax.action.php'),('theme','theme/basic/shop/ajax.action.php'),('normal','shop/cartupdate.php'),('approved','mobile/shop/orderformupdate.php')]: + text = (ROOT/source).read_text(encoding='utf-8').replace("include_once('./_common.php');", "include_once('./bootstrap.php');", 1) + if name=='approved': + text=text.split('// 변수 초기화',1)[0]+"echo json_encode(array('error'=>'', 'checkpoint'=>'validated', 'simulated_pg_approved'=>!empty($GLOBALS['review_pg_approved']), 'canceled_tid'=>isset($GLOBALS['canceled_tid']) ? $GLOBALS['canceled_tid'] : '')); exit;" + (OUT/(name+'.php')).write_text(text, encoding='utf-8') +(OUT/'orderformupdate.php').write_text((OUT/'approved.php').read_text(encoding='utf-8'), encoding='utf-8') +callback=(ROOT/'mobile/shop/inicis/pay_approval.php').read_text(encoding='utf-8').replace("include_once('./_common.php');", "include_once('./bootstrap.php');",1).replace("include_once(G5_MSHOP_PATH.'/settle_inicis.inc.php');", "// PG settings supplied by isolated bootstrap") +(OUT/'callback.php').write_text(callback,encoding='utf-8') + +def reset(approved=False): + db = sqlite3.connect(OUT/'fixture.sqlite') + schema = (ROOT/'install/gnuboard5shop.sql').read_text(encoding='utf-8') + for table in ['item','item_option','cart','inicis_log']: + db.execute('DROP TABLE IF EXISTS g5_shop_'+table) + block = re.search(r'CREATE TABLE IF NOT EXISTS `g5_shop_'+table+r'` \((.*?);',schema,re.S).group(1) + # Source column names/defaults, SQLite storage for deterministic sequential SQL. + cols=[] + for line in block.splitlines(): + m = re.match(r'\s*`([^`]+)`\s+(.+)',line) + if not m: continue + col, decl = m.groups() + typ = 'INTEGER' if re.match(r'(tinyint|smallint|int|bigint)',decl) else 'TEXT' + default = re.search(r"DEFAULT ('[^']*'|\d+)",decl,re.I) + cols.append('"'+col+'" '+typ+' DEFAULT '+(default.group(1) if default else ("0" if typ=='INTEGER' else "''"))) + db.execute('CREATE TABLE g5_shop_'+table+' ('+','.join(cols)+')') + db.execute("INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty,it_sc_type) VALUES ('item','item',1,50000,100,1)") + db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (1,'123456789','item','쇼핑',?,'',0,0,1,1)",(40000 if approved else 50000,)) + if not approved: + db.execute("INSERT INTO g5_shop_item_option (it_id,io_id,io_type,io_use,io_price,io_stock_qty) VALUES ('item','wrap',1,1,1000,100)") + db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (2,'123456789','item','쇼핑',50000,'wrap',1,900,1,1)") + db.commit() + return db + +import os +results=[] +cases=[(r,s) for r in ['ajax','theme','normal'] for s in ['stale','stock','maximum','multi','valid']] +cases += [('normal','direct'),('normal','replace'),('normal','cert'),('approved','forged')] +cases += [('callback',s) for s in ['before','during','cancel_fail','deleted','existing_log','unchanged']] +for route, scenario in cases: + db=reset(route in ['approved','callback']) + atexit.register(db.close) + if scenario == 'valid' or scenario in ['stock','maximum','multi','direct','cert']: + db.execute('UPDATE g5_shop_cart SET io_price=1000 WHERE io_type=1') + if scenario == 'stock': db.execute('UPDATE g5_shop_item_option SET io_stock_qty=1') + if scenario == 'maximum': db.execute('UPDATE g5_shop_item SET it_buy_max_qty=1') + if scenario in ['multi','cert']: + db.execute("INSERT INTO g5_shop_item (it_id,it_name,it_use,it_price,it_stock_qty) VALUES ('other','other',1,30000,100)") + db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty) VALUES (3,'123456789','other','쇼핑',20000,'',0,0,1)") + if scenario=='cert': db.execute("UPDATE g5_shop_cart SET ct_price=30000 WHERE it_id='other'") + if scenario == 'direct': + db.execute('UPDATE g5_shop_cart SET ct_direct=1') + db.execute("INSERT INTO g5_shop_cart (ct_id,od_id,it_id,ct_status,ct_price,io_id,io_type,io_price,ct_qty,ct_select) VALUES (3,'999','item','쇼핑',50000,'',0,0,100,1)") + if route == 'callback' and scenario != 'before': db.execute('UPDATE g5_shop_item SET it_price=40000') + if scenario=='existing_log': db.execute("INSERT INTO g5_shop_inicis_log (oid,P_TID,P_STATUS) VALUES (1001,'test-tid','00')") + db.commit() + before=db.execute('SELECT ct_id,ct_qty FROM g5_shop_cart ORDER BY ct_id').fetchall() + p=subprocess.run([str(PHP)]+PHP_ARGS+[str(OUT/(route+'.php'))],cwd=OUT,env=dict(os.environ,CASE=route,SCENARIO=scenario),capture_output=True,text=True,encoding='utf-8') + after=db.execute('SELECT ct_id,ct_qty FROM g5_shop_cart ORDER BY ct_id').fetchall() + assert p.returncode == 0, p.stderr + response=json.loads(p.stdout) + assert 'PHP Warning' not in p.stderr and 'PHP Fatal' not in p.stderr, p.stderr + if scenario=='unchanged': + assert response['error']=='' and response['checkpoint']=='validated', response + elif scenario in ['valid','replace']: + assert response['error'] == '', (route,scenario,response) + quantities=db.execute('SELECT io_type,SUM(ct_qty) FROM g5_shop_cart GROUP BY io_type ORDER BY io_type').fetchall() + assert quantities == [(0,2),(1,2)] if scenario=='valid' else quantities==[(0,1),(1,1)], quantities + else: + assert response['error'], (route,scenario,response) + if scenario != 'deleted': assert before==after, (route,scenario,before,after) + if route == 'callback': + assert response['simulated_pg_approved'] == (scenario!='before'), response + assert response['canceled_tid'] == ('' if scenario in ['before','unchanged'] else 'test-tid'), response + if scenario not in ['before','unchanged']: + status=db.execute('SELECT P_STATUS FROM g5_shop_inicis_log').fetchone()[0] + assert status==('cancel_failed' if scenario=='cancel_fail' else 'cancel'), status + assert response['session_tid']==('test-tid' if scenario=='cancel_fail' else ''), response + if scenario=='forged': assert response['canceled_tid']=='', response + results.append(route+'/'+scenario) + db.close() +print(str(len(results))+'개 후속 회귀 검증 통과 (SQLite, 모의 PG): '+', '.join(results)) diff --git a/theme/basic/shop/ajax.action.php b/theme/basic/shop/ajax.action.php index d110308c1..b287c6750 100644 --- a/theme/basic/shop/ajax.action.php +++ b/theme/basic/shop/ajax.action.php @@ -82,6 +82,10 @@ switch ($action) { if ($cart_validation['error'] !== '') die(json_encode(array('error' => $cart_validation['error']))); + $cart_merge_error = shop_validate_cart_merge($tmp_cart_id, $cart_validation['products'], !empty($sw_direct)); + if ($cart_merge_error !== '') + die(json_encode(array('error' => $cart_merge_error))); + $ct_count = 0; for($i=0; $i<$count; $i++) { $it_id = isset($_POST['it_id'][$i]) ? safe_replace_regex($_POST['it_id'][$i], 'it_id') : '';