Files
Jyotisha/frontend/tests/epay-settings.test.ts
T

259 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 readProjectFile = (path: string) => readFileSync(new URL(path, root), "utf8");
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/(app)/page.tsx", root), "utf8");
const billingHook = readFileSync(new URL("src/hooks/use-billing-panel.ts", root), "utf8");
const billingPanel = readFileSync(new URL("src/components/billing-panel.tsx", root), "utf8");
const billingSource = `${billingHook}\n${billingPanel}`;
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, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
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, /在对话页开放支付/);
// 原值: billing hook 直接 fetch /api/payment/packages 并读取 payload.enabled。
// 新值: 请求与 60 秒模块级缓存封装在 payment-packages-cache.ts,hook 通过 fetchPaymentPackages 接入;enabled 仍由返回 payload 驱动。
// 原因: BUG-1022;复用同会话套餐结果,减少重复网络请求且不改变支付开关行为。
assert.match(billingSource, /fetchPaymentPackages/);
assert.match(readFileSync(new URL("src/lib/payment-packages-cache.ts", root), "utf8"), /fetch\("\/api\/payment\/packages"/);
assert.match(readFileSync(new URL("src/lib/payment-packages-cache.ts", root), "utf8"), /enabled: record\.enabled === true/);
assert.match(billingHook, /setPaymentEnabled\(next\.enabled\)/);
assert.match(billingSource, /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(billingSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
assert.match(billingSource, /id="membership-plans-tab"/);
assert.match(billingSource, /id="membership-credits-tab"/);
assert.match(billingSource, /立即购买/);
assert.match(billingSource, /套餐支付暂时不可用,请稍后重试/);
assert.doesNotMatch(billingSource, /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'/);
});