251 lines
14 KiB
TypeScript
251 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "../src/lib/epay/encryption-core";
|
|
import { EpayConfigurationError, resolveEpayConfig } from "../src/lib/epay/config-core";
|
|
import { assertConfiguredEpayUrl, assertPublicEpayGateway, assertPublicGatewayUrl, isPublicEpayAddress } from "../src/lib/epay/gateway-policy";
|
|
|
|
const root = new URL("../", import.meta.url);
|
|
const route = readFileSync(new URL("src/app/api/admin/epay-settings/route.ts", root), "utf8");
|
|
const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8");
|
|
const createRoute = readFileSync(new URL("src/app/api/payment/epay/create/route.ts", root), "utf8");
|
|
const notifyRoute = readFileSync(new URL("src/app/api/payment/epay/notify/route.ts", root), "utf8");
|
|
const notifyCore = readFileSync(new URL("src/lib/epay/notify-core.ts", root), "utf8");
|
|
const configRoute = readFileSync(new URL("src/lib/epay/config.ts", root), "utf8");
|
|
const availability = readFileSync(new URL("src/lib/epay/availability.ts", root), "utf8");
|
|
const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8");
|
|
const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8");
|
|
const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8");
|
|
const page = readFileSync(new URL("src/app/page.tsx", root), "utf8");
|
|
const membershipPage = readFileSync(new URL("src/app/membership/page.tsx", root), "utf8");
|
|
const migration = readFileSync(new URL("supabase/migrations/20260805020000_reconcile_payment_admin_schema.sql", root), "utf8");
|
|
|
|
const key = crypto.randomBytes(32).toString("base64");
|
|
|
|
test("易支付密钥 AES-256-GCM 往返、篡改与错误主密钥", () => {
|
|
const encrypted = encryptEpayKey("merchant-secret", key);
|
|
assert.match(encrypted, /^v1\.[^.]+\.[^.]+\.[^.]+$/);
|
|
assert.equal(decryptEpayKey(encrypted, key), "merchant-secret");
|
|
const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith("A") ? "B" : "A"}`;
|
|
assert.throws(() => decryptEpayKey(tampered, key), EpayEncryptionError);
|
|
assert.throws(() => decryptEpayKey(encrypted, crypto.randomBytes(32).toString("base64")), EpayEncryptionError);
|
|
assert.throws(() => encryptEpayKey("merchant-secret", "not-base64"), EpayEncryptionError);
|
|
});
|
|
|
|
test("配置解析数据库优先且无行时回退环境变量", async () => {
|
|
const encrypted = encryptEpayKey("database-secret", key);
|
|
const database = await resolveEpayConfig(async () => ({
|
|
gateway_url: "https://database-pay.example.com/",
|
|
pid: "database-pid",
|
|
encrypted_key: encrypted,
|
|
notify_url: "https://staging.example.com/api/payment/epay/notify",
|
|
return_url: "https://staging.example.com/",
|
|
site_name: "Staging",
|
|
chat_enabled: false,
|
|
}), {
|
|
NODE_ENV: "test",
|
|
EPAY_CONFIG_ENCRYPTION_KEY: key,
|
|
EPAY_GATEWAY_URL: "https://environment-pay.example.com",
|
|
EPAY_PID: "environment-pid",
|
|
EPAY_KEY: "environment-secret",
|
|
});
|
|
assert.equal(database.gatewayUrl.toString(), "https://database-pay.example.com/");
|
|
assert.equal(database.pid, "database-pid");
|
|
assert.equal(database.key, "database-secret");
|
|
assert.equal(database.chatEnabled, false);
|
|
|
|
const environment = await resolveEpayConfig(async () => null, {
|
|
NODE_ENV: "test",
|
|
SITE_ADDRESS: "https://staging.example.com",
|
|
EPAY_GATEWAY_URL: "https://environment-pay.example.com",
|
|
EPAY_PID: "environment-pid",
|
|
EPAY_KEY: "environment-secret",
|
|
EPAY_CHAT_ENABLED: "1",
|
|
});
|
|
assert.equal(environment.chatEnabled, true);
|
|
assert.equal(environment.notifyUrl, "https://staging.example.com/api/payment/epay/notify");
|
|
assert.equal(environment.returnUrl, "https://staging.example.com/");
|
|
});
|
|
|
|
test("生产与默认开发测试配置强制 HTTPS,仅显式开关允许 loopback HTTP", async () => {
|
|
const base = {
|
|
EPAY_GATEWAY_URL: "https://pay.example.com",
|
|
EPAY_PID: "merchant",
|
|
EPAY_KEY: "secret",
|
|
EPAY_NOTIFY_URL: "https://app.example.com/api/payment/epay/notify",
|
|
EPAY_RETURN_URL: "https://app.example.com/",
|
|
};
|
|
for (const name of ["EPAY_GATEWAY_URL", "EPAY_NOTIFY_URL", "EPAY_RETURN_URL"] as const) {
|
|
await assert.rejects(
|
|
resolveEpayConfig(async () => null, { ...base, NODE_ENV: "production", [name]: "http://pay.example.com" }),
|
|
EpayConfigurationError,
|
|
);
|
|
await assert.rejects(
|
|
resolveEpayConfig(async () => null, { ...base, NODE_ENV: "test", [name]: "http://localhost:3000" }),
|
|
EpayConfigurationError,
|
|
);
|
|
}
|
|
|
|
const loopback = await resolveEpayConfig(async () => null, {
|
|
NODE_ENV: "test",
|
|
EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true",
|
|
EPAY_GATEWAY_URL: "http://127.0.0.1:8080",
|
|
EPAY_PID: "merchant",
|
|
EPAY_KEY: "secret",
|
|
EPAY_NOTIFY_URL: "http://localhost:3000/api/payment/epay/notify",
|
|
EPAY_RETURN_URL: "http://[::1]:3000/",
|
|
});
|
|
assert.equal(loopback.gatewayUrl.toString(), "http://127.0.0.1:8080/");
|
|
assert.throws(() => assertConfiguredEpayUrl("http://pay.example.com", {
|
|
NODE_ENV: "test",
|
|
EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true",
|
|
}));
|
|
assert.throws(() => assertConfiguredEpayUrl("http://localhost:3000", {
|
|
NODE_ENV: "production",
|
|
EPAY_ALLOW_INSECURE_LOOPBACK_HTTP: "true",
|
|
}));
|
|
});
|
|
|
|
test("公网网关解析拒绝混入私网地址且每次校验只解析一次", async () => {
|
|
let lookups = 0;
|
|
await assert.rejects(
|
|
assertPublicGatewayUrl("https://pay.example.com", { NODE_ENV: "production" }, async () => {
|
|
lookups += 1;
|
|
return [
|
|
{ address: "93.184.216.34", family: 4 },
|
|
{ address: "127.0.0.1", family: 4 },
|
|
];
|
|
}),
|
|
/内网|保留地址/,
|
|
);
|
|
assert.equal(lookups, 1);
|
|
});
|
|
|
|
test("管理员 API 不回显任何密钥并强制首次显式录入", () => {
|
|
assert.match(route, /requirePermission\("billing\.orders\.read"\)/);
|
|
assert.match(route, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
|
assert.match(route, /\.strict\(\)/);
|
|
assert.match(route, /crypto\.randomUUID\(\)/);
|
|
assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/);
|
|
assert.match(route, /assertConfiguredEpayUrl\(value\)/);
|
|
assert.match(route, /await assertPublicGatewayUrl\(parsed\.data\.gatewayUrl\)/);
|
|
assert.match(route, /chatEnabled: z\.boolean\(\)/);
|
|
assert.match(route, /chatEnabled: row\.chat_enabled/);
|
|
assert.match(route, /queryAdminRows<SettingsRow>/);
|
|
assert.match(route, /select gateway_url, pid, encrypted_key, notify_url, return_url, site_name, chat_enabled, updated_at[\s\S]*from public\.epay_settings[\s\S]*where id = true[\s\S]*limit 1/);
|
|
assert.match(route, /select \* from public\.admin_save_epay_settings\([\s\S]*\$1, \$2, \$3, \$4, \$5, \$6, \$7, \$8, \$9, \$10, \$11, \$12/);
|
|
assert.match(route, /parsed\.data\.chatEnabled,[\s\S]*Boolean\(parsed\.data\.newKey\)/);
|
|
assert.match(route, /keyConfigured/);
|
|
assert.doesNotMatch(route, /createAdminSupabaseClient|\.from\(|\.rpc\(/);
|
|
assert.doesNotMatch(route, /NextResponse\.json\([^\n]*(?:encrypted_key|newKey|encryptedKey|maskedKey|keyMask)/);
|
|
assert.doesNotMatch(route, /BETTER_AUTH_SECRET/);
|
|
});
|
|
|
|
test("迁移前仅在配置表不存在时继续使用环境变量", () => {
|
|
assert.match(configRoute, /error\?\.code === "42P01"/);
|
|
assert.match(route, /isPostgresError\(error\) && error\.code === "42P01"/);
|
|
assert.match(route, /throw error/);
|
|
assert.match(configRoute, /if \(error\) throw new Error\(\)/);
|
|
});
|
|
|
|
test("支付调用点等待异步数据库配置", () => {
|
|
assert.match(createRoute, /await readEpayConfig\(\)/);
|
|
assert.match(notifyRoute, /readConfig: readEpayConfig/);
|
|
assert.match(notifyCore, /await dependencies\.readConfig\(\)/);
|
|
assert.match(notifyRoute, /export async function POST/);
|
|
assert.match(notifyRoute, /export async function GET/);
|
|
});
|
|
|
|
test("统一支付页面含默认折叠的 Z-Pay 渠道配置与永不预填的 Password", () => {
|
|
assert.match(management, /<Collapse[\s\S]*defaultActiveKey=\{\[\]\}[\s\S]*label: "Z-Pay(易支付)渠道配置"/);
|
|
assert.match(management, /children: \([\s\S]*<Card[\s\S]*测试可用性(当前已保存配置)[\s\S]*保存配置/);
|
|
assert.match(management, /\/api\/admin\/epay-settings/);
|
|
assert.match(management, /<Input\.Password/);
|
|
assert.match(management, /placeholder="留空保持原密钥"/);
|
|
assert.match(management, /setFieldValue\("newKey", ""\)/);
|
|
assert.doesNotMatch(management, /value=\{.*(?:key|secret)/i);
|
|
});
|
|
|
|
test("支付开关贯通迁移、公共套餐 API、创建订单和会员页", () => {
|
|
assert.match(migration, /chat_enabled boolean not null default false/);
|
|
assert.match(migration, /p_chat_enabled boolean/);
|
|
assert.match(migration, /'chatEnabled'/);
|
|
assert.match(availability, /EPAY_CHAT_ENABLED/);
|
|
assert.doesNotMatch(availability, /EPAY_CONFIG_ENCRYPTION_KEY|decryptEpayKey/);
|
|
assert.match(packagesRoute, /enabled: false, packages: \[\]/);
|
|
assert.match(packagesRoute, /enabled: true/);
|
|
assert.match(createRoute, /在线支付暂未开放/);
|
|
assert.match(createRoute, /EPAY_DISABLED/);
|
|
assert.match(createRoute, /await readEpayAvailability\(\)/);
|
|
assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()"));
|
|
assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("billing_products"));
|
|
assert.match(management, /在对话页开放支付/);
|
|
assert.match(membershipPage, /fetch\("\/api\/payment\/packages"/);
|
|
assert.match(membershipPage, /payload\?\..*enabled === true/);
|
|
assert.match(membershipPage, /setPaymentEnabled\(true\)/);
|
|
assert.match(membershipPage, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/);
|
|
assert.doesNotMatch(page, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/);
|
|
});
|
|
|
|
test("创建订单返回已签名收银台 URL 且不服务端请求网关", () => {
|
|
assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/);
|
|
assert.match(createRoute, /const signedParams = \{\s*\.\.\.params,\s*sign: epaySign\(params, config\.key\),\s*sign_type: "MD5",?\s*\}/);
|
|
assert.match(createRoute, /const payUrl = new URL\(submitUrl\)/);
|
|
assert.match(createRoute, /payUrl\.searchParams\.set\(name, value\)/);
|
|
assert.match(createRoute, /NextResponse\.json\(\{\s*orderNo,\s*payUrl: payUrl\.toString\(\),\s*qrCode: null,\s*product: productSnapshot,?\s*\}\)/);
|
|
for (const field of ["money", "name", "notify_url", "out_trade_no", "pid", "return_url", "sitename", "type", "sign", "sign_type"]) assert.match(createRoute, new RegExp(field));
|
|
assert.doesNotMatch(createRoute, /fetch\(submitUrl|document\.createElement\("form"\)|submitUrl:|fields[, }]/);
|
|
assert.doesNotMatch(createRoute, /NextResponse\.json\([^\n]*config\.key|searchParams\.set\([^\n]*config\.key/);
|
|
|
|
assert.match(membershipPage, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
|
|
assert.match(membershipPage, /id="membership-plans-tab"/);
|
|
assert.match(membershipPage, /id="membership-credits-tab"/);
|
|
assert.match(membershipPage, /立即购买/);
|
|
assert.match(membershipPage, /套餐支付暂时不可用,请稍后重试/);
|
|
assert.doesNotMatch(membershipPage, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
|
|
});
|
|
|
|
test("网关探测固定已验证公网 IP、保留 TLS hostname 且限制重定向与响应", () => {
|
|
assert.match(testRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
|
assert.match(testRoute, /probePublicEpayGateway\(submitUrl\)/);
|
|
assert.doesNotMatch(testRoute, /fetch\(|method: "POST"|payment_orders|\.text\(\)|\.json\(\)/);
|
|
assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status,/);
|
|
assert.doesNotMatch(testRoute, /pid:|key:|gatewayUrl:|headers:|body:|payment_orders/);
|
|
assert.match(gatewayPolicy, /const resolved = await withinTimeout\([\s\S]*resolvePublicUrl\(value, options\.lookup \?\? defaultLookup\)/);
|
|
assert.match(gatewayPolicy, /const pinned = resolved\.addresses\[0\]!/);
|
|
assert.match(gatewayPolicy, /servername: isIP\(hostname\) \? undefined : hostname/);
|
|
assert.match(gatewayPolicy, /lookup: pinnedAddressLookup\(pinned\)/);
|
|
assert.match(gatewayPolicy, /requestPinnedHttps\([\s\S]*resolved,[\s\S]*"HEAD"/);
|
|
assert.match(gatewayPolicy, /headStatus === 405 \|\| headStatus === 501[\s\S]*requestPinnedHttps\([\s\S]*resolved,[\s\S]*"GET"/);
|
|
assert.match(gatewayPolicy, /status >= 300 && status < 400/);
|
|
assert.match(gatewayPolicy, /maxResponseBytes|setTimeout\(timeoutMs/);
|
|
assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/);
|
|
});
|
|
|
|
test("纯地址判断拒绝私网、回环、链路本地并接受公网", () => {
|
|
for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) {
|
|
assert.equal(isPublicEpayAddress(address), false, address);
|
|
}
|
|
for (const address of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) {
|
|
assert.equal(isPublicEpayAddress(address), true, address);
|
|
}
|
|
assert.throws(() => assertPublicEpayGateway("http://localhost/pay"));
|
|
assert.throws(() => assertPublicEpayGateway("http://127.0.0.1/pay"));
|
|
assert.doesNotThrow(() => assertPublicEpayGateway("https://pay.example.com"));
|
|
});
|
|
|
|
test("迁移锁定单行、RLS、最小权限与脱敏原子审计", () => {
|
|
assert.match(migration, /create table if not exists public\.epay_settings/);
|
|
assert.match(migration, /id boolean primary key default true check \(id\)/);
|
|
assert.match(migration, /alter table public\.epay_settings enable row level security/);
|
|
assert.match(migration, /revoke all on table public\.epay_settings from public, anon, authenticated, service_role/);
|
|
assert.match(migration, /grant select on table public\.epay_settings to service_role/);
|
|
assert.match(migration, /security definer/);
|
|
assert.match(migration, /insert into audit\.admin_audit_logs/);
|
|
assert.match(migration, /'keyConfigured'/);
|
|
assert.match(migration, /'keyChanged'/);
|
|
const auditBlock = migration.slice(migration.indexOf("insert into audit.admin_audit_logs"));
|
|
assert.doesNotMatch(auditBlock, /'encryptedKey'|'encrypted_key'|'secret'|jsonb_build_object\([\s\S]*?'key'/);
|
|
});
|