Files
Jyotisha/frontend/tests/redeem-orders-contract.test.ts
T
Jesse d63f10dcc2
Staging Backend Quality Gate / validate (push) Successful in 15m31s
Staging Backend Quality Gate / publish (push) Successful in 23m10s
fix(db): isolate redemption migration from identity schema
2026-08-07 19:05:01 +08:00

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,
);
});