212 lines
7.8 KiB
TypeScript
212 lines
7.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import {
|
|
formatPaymentOrders,
|
|
PAYMENT_ORDERS_SELECT,
|
|
} from "../src/lib/payment-orders.ts";
|
|
import {
|
|
redeemErrorResponse,
|
|
redeemInputErrorResponse,
|
|
} from "../src/lib/redeem-response.ts";
|
|
|
|
const redeemRoute = readFileSync(
|
|
new URL("../src/app/api/redeem/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const ordersRoute = readFileSync(
|
|
new URL("../src/app/api/payment/orders/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
// Only the supabase compatibility migration exists: business-schema
|
|
// migrations must not be duplicated into db/migrations (identity-only
|
|
// fixtures apply that directory without the business schema; see BUG-144).
|
|
const redeemSecurityMigration = readFileSync(
|
|
new URL("../supabase/migrations/20260807030000_redeem_security.sql", import.meta.url),
|
|
"utf8",
|
|
);
|
|
|
|
test("redeem API trims surrounding whitespace only, never changes case, and never pre-validates format", () => {
|
|
assert.match(redeemRoute, /\.trim\(\)/);
|
|
assert.doesNotMatch(redeemRoute, /normalizeRedeemCode|\.toUpperCase\(\)/);
|
|
// No client-side format short-circuit: lowercase or malformed non-empty
|
|
// values must reach the RPC so they count against the failure rate limit.
|
|
assert.doesNotMatch(redeemRoute, /JYOTISH-\[A-Z0-9\]|codeFormat/);
|
|
assert.match(redeemRoute, /if \(!code\)/);
|
|
// The trimmed input is hashed as-is so a lowercase attempt cannot match.
|
|
assert.match(redeemRoute, /hashRedeemCode\(code\)/);
|
|
});
|
|
|
|
test("redeem API returns stable error codes with the 429 rate-limit copy", () => {
|
|
assert.deepEqual(redeemErrorResponse("rate_limited"), {
|
|
status: 429,
|
|
message: "请求过于频繁",
|
|
code: "rate_limited",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("invalid_code"), {
|
|
status: 404,
|
|
message: "兑换码不存在",
|
|
code: "invalid_code",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("revoked_code"), {
|
|
status: 404,
|
|
message: "兑换码已撤销",
|
|
code: "invalid_code",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("expired_code"), {
|
|
status: 410,
|
|
message: "兑换码已过期",
|
|
code: "expired_code",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("already_redeemed"), {
|
|
status: 409,
|
|
message: "兑换码已被使用",
|
|
code: "already_redeemed",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("account_not_eligible"), {
|
|
status: 403,
|
|
message: "该兑换码不适用于当前账户",
|
|
code: "account_not_eligible",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("profile_missing"), {
|
|
status: 500,
|
|
message: "系统异常,请稍后重试",
|
|
code: "system_error",
|
|
});
|
|
assert.deepEqual(redeemErrorResponse("unknown_future_code"), {
|
|
status: 500,
|
|
message: "系统异常,请稍后重试",
|
|
code: "system_error",
|
|
});
|
|
assert.deepEqual(redeemInputErrorResponse(), {
|
|
status: 400,
|
|
message: "请输入有效兑换码",
|
|
code: "invalid_code",
|
|
});
|
|
|
|
assert.match(redeemRoute, /redeemErrorResponse\(/);
|
|
assert.match(redeemRoute, /code: mapped\.code/);
|
|
assert.match(redeemRoute, /status: 401/);
|
|
});
|
|
|
|
test("redeem API success returns awardedCredits and the latest balance", () => {
|
|
assert.match(redeemRoute, /awardedCredits: result\.awarded_credits/);
|
|
assert.match(redeemRoute, /credits: result\.credits/);
|
|
assert.match(redeemRoute, /result\??\.error_code/);
|
|
});
|
|
|
|
test("payment orders API is authenticated, owner-filtered and limited", () => {
|
|
assert.match(ordersRoute, /请先登录/);
|
|
assert.match(ordersRoute, /status: 401/);
|
|
assert.match(ordersRoute, /\.eq\("user_id", user\.id\)/);
|
|
assert.match(ordersRoute, /\.order\("created_at", \{ ascending: false \}\)/);
|
|
assert.match(ordersRoute, /\.limit\(MAX_ORDERS\)/);
|
|
assert.doesNotMatch(ordersRoute, /epay_trade_no|raw_notify_payload_hash/);
|
|
});
|
|
|
|
test("payment orders summary is desensitized to the allowlist", () => {
|
|
assert.deepEqual(PAYMENT_ORDERS_SELECT, [
|
|
"order_no",
|
|
"product_code",
|
|
"product_snapshot",
|
|
"money_cents",
|
|
"status",
|
|
"grant_status",
|
|
"created_at",
|
|
"paid_at",
|
|
]);
|
|
assert.doesNotMatch(
|
|
PAYMENT_ORDERS_SELECT.join(","),
|
|
/user_id|epay_trade_no|raw_notify|entitlement/,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
formatPaymentOrders([
|
|
{
|
|
order_no: "JY-ONE",
|
|
product_code: "standard_monthly",
|
|
product_snapshot: { name: "标准月卡" },
|
|
money_cents: 9900,
|
|
status: "paid",
|
|
grant_status: "granted",
|
|
created_at: new Date("2026-08-07T00:00:00.000Z"),
|
|
paid_at: new Date("2026-08-07T00:05:00.000Z"),
|
|
},
|
|
{
|
|
order_no: "JY-TWO",
|
|
product_code: null,
|
|
product_snapshot: null,
|
|
money_cents: 990,
|
|
status: "pending",
|
|
grant_status: "pending",
|
|
created_at: "2026-08-06T00:00:00.000Z",
|
|
paid_at: null,
|
|
},
|
|
]),
|
|
[
|
|
{
|
|
orderNo: "JY-ONE",
|
|
product: "standard_monthly",
|
|
name: "标准月卡",
|
|
price: 9900,
|
|
status: "paid",
|
|
grantStatus: "granted",
|
|
createdAt: "2026-08-07T00:00:00.000Z",
|
|
paidAt: "2026-08-07T00:05:00.000Z",
|
|
},
|
|
{
|
|
orderNo: "JY-TWO",
|
|
product: null,
|
|
name: null,
|
|
price: 990,
|
|
status: "pending",
|
|
grantStatus: "pending",
|
|
createdAt: "2026-08-06T00:00:00.000Z",
|
|
paidAt: null,
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test("redeem security migration adds a hash-free failure-only audit table with least privilege", () => {
|
|
const tableBlock = redeemSecurityMigration.match(
|
|
/create table if not exists public\.redemption_attempts[\s\S]*?\);/,
|
|
);
|
|
assert.ok(tableBlock, "redemption_attempts table must be created");
|
|
assert.match(tableBlock[0], /user_id uuid not null references auth\.users\(id\) on delete cascade/);
|
|
assert.match(tableBlock[0], /created_at timestamptz not null default now\(\)/);
|
|
assert.match(tableBlock[0], /generated always as identity/);
|
|
// Failure-only: no success column, no plaintext code and no code hash.
|
|
assert.doesNotMatch(tableBlock[0], /success|code|hash|mask/i);
|
|
|
|
assert.match(redeemSecurityMigration, /drop function if exists public\.redeem_code\(text\);/);
|
|
assert.match(redeemSecurityMigration, /alter table public\.redemption_attempts enable row level security/);
|
|
assert.match(redeemSecurityMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/);
|
|
assert.match(redeemSecurityMigration, /grant select on table public\.redemption_attempts to service_role/);
|
|
assert.match(redeemSecurityMigration, /pg_advisory_xact_lock/);
|
|
assert.match(redeemSecurityMigration, /interval '10 minutes'/);
|
|
assert.match(redeemSecurityMigration, /v_failed >= 5/);
|
|
// Business failures insert a single-column audit row; success deletes all
|
|
// of the account's attempts instead of inserting a permanent success row.
|
|
assert.match(redeemSecurityMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/);
|
|
assert.match(redeemSecurityMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/);
|
|
// The redemption-code row lock is kept and the credit_transactions
|
|
// unique constraint is preserved untouched (nothing is dropped).
|
|
assert.match(redeemSecurityMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/);
|
|
assert.doesNotMatch(redeemSecurityMigration, /alter table public\.credit_transactions/);
|
|
assert.match(redeemSecurityMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/);
|
|
assert.match(redeemSecurityMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/);
|
|
});
|
|
|
|
test("db/migrations must not carry the business-schema redemption copy (BUG-144 guard)", () => {
|
|
assert.equal(
|
|
existsSync(
|
|
new URL(
|
|
"../db/migrations/20260807020000_redeem_security.sql",
|
|
import.meta.url,
|
|
),
|
|
),
|
|
false,
|
|
);
|
|
});
|