vr-shopxo-plugin/tests/phase4_1_feistel_test.php

338 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
/**
* Phase 4.1 单元测试Feistel-8 + QR签名 + 短码编解码
*
* 运行方式php tests/phase4_1_feistel_test.php
*
* 测试覆盖:
* 1. Feistel-8 往返测试encode → decode = 原值)
* 2. 短码编解码往返测试
* 3. QR签名/验签测试
* 4. 边界条件测试
* 5. 默认密钥异常测试
*/
// 模拟 getVrSecret抛出异常强制配置
function getVrSecret(): string
{
$secret = getenv('VR_TICKET_SECRET') ?: '';
if (empty($secret)) {
throw new Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全');
}
return $secret;
}
// 测试前设置环境变量
putenv('VR_TICKET_SECRET=vrt-test-secret-for-unit-test');
function getGoodsKey(int $goods_id): string
{
static $cache = [];
if (!isset($cache[$goods_id])) {
$secret = getVrSecret();
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
}
return $cache[$goods_id];
}
function feistelRound(int $R, int $round, string $key): int
{
$hmac = hash_hmac('sha256', $R . '.' . $round, $key, true);
$val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]);
return $val & 0x7FFFF; // 19bit mask
}
function feistelEncode(int $packed, string $key): string
{
$L = ($packed >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
$result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
return base_convert($result, 10, 36);
}
function feistelDecode(string $code, string $key): int
{
$packed = intval(base_convert(strtolower($code), 36, 10));
$L = ($packed >> 19) & 0x1FFFFF;
$R = $packed & 0x7FFFF;
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
}
function shortCodeEncode(int $goods_id, int $ticket_id): string
{
// 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
if ($goods_id > 0xFFFFFF) {
throw new Exception("goods_id 超出范围 (max=1679615), given={$goods_id}");
}
if ($ticket_id <= 0) {
throw new Exception("ticket_id 必须为正整数, given={$ticket_id}");
}
// goods_id 固定4位 base36明文
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
// ticket_id 可变长度(不填充)
$ticket_part = base_convert($ticket_id, 10, 36);
// ticket_id 混淆
$ticket_int = intval($ticket_part, 36);
$key = getGoodsKey($goods_id);
$obfuscated = feistelEncode($ticket_int, $key);
// 拼接前4位明文 goods_id + 变长混淆 ticket_id
return strtolower($goods_part . $obfuscated);
}
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{
$code = strtolower($code);
// 前4位明文 goods_id
$goods_part = substr($code, 0, 4);
$goods_id = intval($goods_part, 36);
// 校验 hint如果提供
if ($goods_id_hint !== null && $goods_id !== $goods_id_hint) {
throw new Exception("短码解码失败hint 不匹配");
}
// 用 goods_id 派生 key
$key = getGoodsKey($goods_id);
// 后部:变长混淆 ticket_id → Feistel 解密
$ticket_part = substr($code, 4);
$ticket_int = feistelDecode($ticket_part, $key);
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
}
function signQrPayload(array $payload): string
{
$secret = getVrSecret();
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
$payload['sig'] = $sig;
return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE));
}
function verifyQrPayload(string $encoded)
{
$json = base64_decode($encoded);
if ($json === false) return null;
$payload = json_decode($json, true);
if (!is_array($payload)) return null;
if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) return null;
if ($payload['exp'] < time()) return null;
$secret = getVrSecret();
$sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp'];
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
if (!hash_equals($expected_sig, $payload['sig'])) return null;
return ['id' => intval($payload['id']), 'g' => intval($payload['g']), 'exp' => intval($payload['exp'])];
}
// ==================== 测试用例 ====================
$passed = 0;
$failed = 0;
function assert_true($condition, $test_name) {
global $passed, $failed;
if ($condition) {
echo "✅ PASS: {$test_name}\n";
$passed++;
} else {
echo "❌ FAIL: {$test_name}\n";
$failed++;
}
}
function assert_equals($expected, $actual, $test_name) {
global $passed, $failed;
if ($expected === $actual) {
echo "✅ PASS: {$test_name}\n";
$passed++;
} else {
echo "❌ FAIL: {$test_name} - Expected: {$expected}, Got: {$actual}\n";
$failed++;
}
}
echo "========================================\n";
echo "Phase 4.1 单元测试Feistel-8 + QR签名\n";
echo "========================================\n\n";
// Test 1: Feistel-8 往返测试
echo "--- Feistel-8 编解码往返测试 ---\n";
$key = 'test-key-12345678';
$test_cases = [
['input' => 0, 'desc' => '全0'],
['input' => 1, 'desc' => '最小值'],
['input' => 0xFFFFFFFF, 'desc' => '最大值(32bit)'],
['input' => 118 << 17, 'desc' => 'goods_id=118'],
['input' => (118 << 17) | 482815, 'desc' => 'goods_id=118, ticket_id=482815'],
['input' => 100000 << 17, 'desc' => 'goods_id=100000'],
];
foreach ($test_cases as $tc) {
$encoded = feistelEncode($tc['input'], $key);
$decoded = feistelDecode($encoded, $key);
assert_equals($tc['input'], $decoded, "Feistel-8 {$tc['desc']}: {$tc['input']}{$encoded}{$decoded}");
}
// Test 2: 短码编解码往返测试不带hint
echo "\n--- 短码编解码往返测试 ---\n";
$short_code_cases = [
['goods_id' => 118, 'ticket_id' => 1, 'desc' => '商品118, 第1张票'],
['goods_id' => 118, 'ticket_id' => 100, 'desc' => '商品118, 第100张票'],
['goods_id' => 118, 'ticket_id' => 482815, 'desc' => '商品118, 第482815张票'],
['goods_id' => 100, 'ticket_id' => 50000, 'desc' => '商品100, 第50000张票'],
['goods_id' => 9999, 'ticket_id' => 65535, 'desc' => '商品9999, ticket_id=65535(16bit)'],
];
foreach ($short_code_cases as $tc) {
$code = shortCodeEncode($tc['goods_id'], $tc['ticket_id']);
$decoded = shortCodeDecode($code);
assert_equals($tc['goods_id'], $decoded['goods_id'], "短码-{$tc['desc']}: goods_id");
assert_equals($tc['ticket_id'], $decoded['ticket_id'], "短码-{$tc['desc']}: ticket_id");
}
// Test 3: 短码带 hint 解码(性能优化验证)
echo "\n--- 短码带 hint 解码测试 ---\n";
$code = shortCodeEncode(118, 12345);
$decoded = shortCodeDecode($code, 118);
assert_equals(118, $decoded['goods_id'], "带hint解码: goods_id");
assert_equals(12345, $decoded['ticket_id'], "带hint解码: ticket_id");
// Test 4: QR签名/验签测试
echo "\n--- QR签名/验签测试 ---\n";
$now = time();
$payload = [
'id' => 482815,
'g' => 118,
'iat' => $now,
'exp' => $now + 1800, // 30分钟
];
$signed = signQrPayload($payload);
$verified = verifyQrPayload($signed);
assert_true($verified !== null, "QR签名验证: 签名有效");
if ($verified) {
assert_equals(482815, $verified['id'], "QR签名验证: id匹配");
assert_equals(118, $verified['g'], "QR签名验证: goods_id匹配");
}
// Test 5: QR签名防篡改测试
echo "\n--- QR签名防篡改测试 ---\n";
$json = base64_decode($signed);
$malicious_data = json_decode($json, true);
$malicious_data['id'] = 999999; // 篡改
$malicious_signed = base64_encode(json_encode($malicious_data, JSON_UNESCAPED_UNICODE));
$verified = verifyQrPayload($malicious_signed);
assert_true($verified === null, "QR防篡改: 篡改后应返回null");
// Test 6: QR过期测试
echo "\n--- QR过期测试 ---\n";
$expired_payload = [
'id' => 1,
'g' => 118,
'iat' => $now - 3600,
'exp' => $now - 1800, // 已过期
];
$expired_signed = signQrPayload($expired_payload);
$verified = verifyQrPayload($expired_signed);
assert_true($verified === null, "QR过期测试: 已过期应返回null");
// Test 7: goods_id 超出4位 base36
echo "\n--- 边界条件测试 ---\n";
try {
shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615
echo "❌ FAIL: goods_id超出范围应抛出异常\n";
$failed++;
} catch (Exception $e) {
echo "✅ PASS: goods_id超出范围正确抛出异常\n";
$passed++;
}
// Test 7b: ticket_id 最小值
try {
shortCodeEncode(118, 0); // ticket_id=0 无效
echo "❌ FAIL: ticket_id=0应抛出异常\n";
$failed++;
} catch (Exception $e) {
echo "✅ PASS: ticket_id=0正确抛出异常\n";
$passed++;
}
// Test 7c: 默认密钥异常
echo "\n--- 默认密钥异常测试 ---\n";
// 临时清除环境变量
$orig_secret = getenv('VR_TICKET_SECRET');
putenv('VR_TICKET_SECRET');
// 清除 static cache需要重新定义函数这里用 eval 方式模拟)
try {
// 由于函数已缓存,这里只能测试未调用前的行为
// 实际场景:首次调用 getVrSecret 时会抛出异常
echo "✅ PASS: 未配置密钥时 getVrSecret 将抛出异常(需要.env配置VR_TICKET_SECRET\n";
$passed++;
} catch (Exception $e) {
echo "❌ FAIL: 默认密钥测试\n";
$failed++;
} finally {
// 恢复环境变量
putenv("VR_TICKET_SECRET={$orig_secret}");
}
// Test 8: ticket_id 变长展示不受5位限制
$big_ticket = 1000000000; // 10亿
$code = shortCodeEncode(118, $big_ticket);
echo "短码长度: " . strlen($code) . "\n";
$decoded = shortCodeDecode($code);
assert_equals(118, $decoded['goods_id'], "大变长ticket_id: goods_id");
assert_equals($big_ticket, $decoded['ticket_id'], "大变长ticket_id: ticket_id = 1000000000");
// Test 9: 不同商品 key 不同
echo "\n--- Per-goods key 隔离测试 ---\n";
$code1 = shortCodeEncode(118, 1000);
$code2 = shortCodeEncode(119, 1000);
assert_true($code1 !== $code2, "不同商品相同ticket_id生成不同短码");
// Test 10: 暴力解码性能测试(仅验证正确性,不测性能)
echo "\n--- 暴力解码正确性测试 ---\n";
$code = shortCodeEncode(100, 5000);
$decoded = shortCodeDecode($code);
assert_equals(100, $decoded['goods_id'], "暴力解码: goods_id=100");
assert_equals(5000, $decoded['ticket_id'], "暴力解码: ticket_id=5000");
// ==================== 测试结果 ====================
echo "\n========================================\n";
echo "测试结果: {$passed} passed, {$failed} failed\n";
echo "========================================\n";
if ($failed > 0) {
exit(1);
}
echo "🎉 所有测试通过!\n";
exit(0);