487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
import { Client } from "pg";
|
|
|
|
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
|
|
|
const runnerPath = fileURLToPath(
|
|
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
|
);
|
|
|
|
const ids = {
|
|
billing: "71000000-0000-4000-8000-000000000001",
|
|
support: "71000000-0000-4000-8000-000000000002",
|
|
oneTimeUser: "72000000-0000-4000-8000-000000000001",
|
|
retryUser: "72000000-0000-4000-8000-000000000002",
|
|
compensateUser: "72000000-0000-4000-8000-000000000003",
|
|
oneTimeProduct: "73000000-0000-4000-8000-000000000001",
|
|
retryProduct: "73000000-0000-4000-8000-000000000002",
|
|
compensateProduct: "73000000-0000-4000-8000-000000000003",
|
|
};
|
|
|
|
const orderNo = (suffix: string) => `JYADJUST${suffix.padEnd(18, "0")}`;
|
|
|
|
function errorText(error: unknown) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
test("billing order adjustments and redemption reasons are atomic and audited", async () => {
|
|
const fixture = startPostgresFixture();
|
|
const admin = new Client({
|
|
connectionString: fixture.connectionUrl("postgres", "postgres-test-password"),
|
|
});
|
|
const adminRuntime = new Client({
|
|
connectionString: fixture.connectionUrl(
|
|
"admin_runtime",
|
|
"admin-runtime-test-password",
|
|
),
|
|
});
|
|
const serviceRuntime = new Client({
|
|
connectionString: fixture.connectionUrl(
|
|
"service_runtime",
|
|
"service-runtime-test-password",
|
|
),
|
|
});
|
|
const sql = (statement: string) => fixture.psql(statement);
|
|
|
|
try {
|
|
const migration = spawnSync(process.execPath, [runnerPath], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
SCHEMA_DATABASE_URL: fixture.connectionUrl(
|
|
"schema_owner",
|
|
"schema-owner-test-password",
|
|
),
|
|
},
|
|
});
|
|
assert.equal(migration.status, 0, migration.stderr);
|
|
await Promise.all([
|
|
admin.connect(),
|
|
adminRuntime.connect(),
|
|
serviceRuntime.connect(),
|
|
]);
|
|
await serviceRuntime.query("set role service_role");
|
|
|
|
fixture.psqlAs(
|
|
"identity_runtime",
|
|
"identity-runtime-test-password",
|
|
`insert into identity.users (id,name,email,email_verified,email_verified_at,role) values
|
|
('${ids.billing}','Billing Admin','billing-adjust@example.com',true,now(),'admin'),
|
|
('${ids.support}','Support Admin','support-adjust@example.com',true,now(),'admin'),
|
|
('${ids.oneTimeUser}','One Time User','one-time@example.com',true,now(),'user'),
|
|
('${ids.retryUser}','Retry User','retry@example.com',true,now(),'user'),
|
|
('${ids.compensateUser}','Compensate User','compensate@example.com',true,now(),'user')`,
|
|
);
|
|
sql(`
|
|
insert into public.admin_users(user_id,created_by) values
|
|
('${ids.billing}','${ids.billing}'),('${ids.support}','${ids.billing}');
|
|
insert into public.admin_user_roles(admin_user_id,role_id,assigned_by)
|
|
select v.user_id,r.id,'${ids.billing}'::uuid
|
|
from (values
|
|
('${ids.billing}'::uuid,'billing_admin'),
|
|
('${ids.support}'::uuid,'support')
|
|
) v(user_id,role_code)
|
|
join public.admin_roles r on r.code=v.role_code;
|
|
insert into public.billing_products(
|
|
id,code,version,name,product_type,billing_period,interval_count,
|
|
price_cents,enabled,status,effective_from,one_time_per_user
|
|
) values
|
|
('${ids.oneTimeProduct}','once_credits',1,'一次性积分','credit_pack','none',0,700,true,'published',now(),true),
|
|
('${ids.retryProduct}','retry_credits',1,'重试积分','credit_pack','none',0,500,true,'published',now(),false),
|
|
('${ids.compensateProduct}','compensate_credits',1,'补偿积分','credit_pack','none',0,400,true,'published',now(),false);
|
|
insert into public.product_entitlements(product_id,feature_key,allowance_type,allowance_count,reset_period) values
|
|
('${ids.oneTimeProduct}','chat.standard','credits',7,'none'),
|
|
('${ids.retryProduct}','chat.standard','credits',5,'none'),
|
|
('${ids.compensateProduct}','chat.standard','credits',4,'none');
|
|
`);
|
|
|
|
const insertOrder = async (input: {
|
|
suffix: string;
|
|
userId: string;
|
|
productId: string;
|
|
code: string;
|
|
price: number;
|
|
credits: number;
|
|
oneTime: boolean;
|
|
entitlements?: unknown[];
|
|
}) => {
|
|
const entitlements = input.entitlements ?? [
|
|
{
|
|
featureKey: "chat.standard",
|
|
allowanceType: "credits",
|
|
allowanceCount: input.credits,
|
|
resetPeriod: "none",
|
|
modelTier: null,
|
|
fairUsePolicyId: null,
|
|
metadata: {},
|
|
},
|
|
];
|
|
const result = await admin.query<{ id: string }>(
|
|
`insert into public.payment_orders (
|
|
order_no,user_id,package_id,product_id,product_code,product_version,
|
|
product_snapshot,entitlement_snapshot,money_cents,currency,credits,
|
|
grant_type,grant_status
|
|
) values (
|
|
$1,$2,null,$3,$4,1,
|
|
jsonb_build_object(
|
|
'id',$3::uuid,'code',$4::text,'version',1,'name',$5::text,'description','',
|
|
'productType','credit_pack','billingPeriod','none','intervalCount',0,
|
|
'priceCents',$6::integer,'currency','CNY','oneTimePerUser',$7::boolean
|
|
),
|
|
$8::jsonb,$6,'CNY',$9,'credits','pending'
|
|
) returning id`,
|
|
[
|
|
orderNo(input.suffix),
|
|
input.userId,
|
|
input.productId,
|
|
input.code,
|
|
input.code,
|
|
input.price,
|
|
input.oneTime,
|
|
JSON.stringify(entitlements),
|
|
input.credits,
|
|
],
|
|
);
|
|
return result.rows[0].id;
|
|
};
|
|
|
|
const onceOrderA = await insertOrder({
|
|
suffix: "ONCEA",
|
|
userId: ids.oneTimeUser,
|
|
productId: ids.oneTimeProduct,
|
|
code: "once_credits",
|
|
price: 700,
|
|
credits: 7,
|
|
oneTime: true,
|
|
});
|
|
await insertOrder({
|
|
suffix: "ONCEB",
|
|
userId: ids.oneTimeUser,
|
|
productId: ids.oneTimeProduct,
|
|
code: "once_credits",
|
|
price: 700,
|
|
credits: 7,
|
|
oneTime: true,
|
|
});
|
|
|
|
const raceClients = [0, 1].map(
|
|
() =>
|
|
new Client({
|
|
connectionString: fixture.connectionUrl(
|
|
"service_runtime",
|
|
"service-runtime-test-password",
|
|
),
|
|
}),
|
|
);
|
|
await Promise.all(raceClients.map((client) => client.connect()));
|
|
await Promise.all(
|
|
raceClients.map((client) => client.query("set role service_role")),
|
|
);
|
|
try {
|
|
const results = await Promise.all([
|
|
raceClients[0].query(
|
|
"select * from public.settle_order($1,$2,$3,$4)",
|
|
[orderNo("ONCEA"), "trade-once-a", 700, "hash-once-a"],
|
|
),
|
|
raceClients[1].query(
|
|
"select * from public.settle_order($1,$2,$3,$4)",
|
|
[orderNo("ONCEB"), "trade-once-b", 700, "hash-once-b"],
|
|
),
|
|
]);
|
|
assert.deepEqual(
|
|
results.map((result) => result.rows[0].success).sort(),
|
|
[false, true],
|
|
);
|
|
assert.deepEqual(
|
|
results.map((result) => result.rows[0].status).sort(),
|
|
["one_time_limit", "paid"],
|
|
);
|
|
} finally {
|
|
await Promise.all(raceClients.map((client) => client.end()));
|
|
}
|
|
assert.equal(
|
|
sql(`select credits from public.profiles where id='${ids.oneTimeUser}'`),
|
|
"7",
|
|
);
|
|
assert.equal(
|
|
sql(`select count(*) from public.user_product_redemptions where user_id='${ids.oneTimeUser}' and product_code='once_credits'`),
|
|
"1",
|
|
);
|
|
await assert.rejects(
|
|
admin.query(
|
|
"update public.payment_orders set product_snapshot=jsonb_set(product_snapshot,'{name}','\"tampered\"') where id=$1",
|
|
[onceOrderA],
|
|
),
|
|
(error) => /payment_order_snapshot_immutable/.test(errorText(error)),
|
|
);
|
|
|
|
const retryOrder = await insertOrder({
|
|
suffix: "RETRY",
|
|
userId: ids.retryUser,
|
|
productId: ids.retryProduct,
|
|
code: "retry_credits",
|
|
price: 500,
|
|
credits: 5,
|
|
oneTime: false,
|
|
});
|
|
await admin.query(`
|
|
create function public.test_fail_retry_grant() returns trigger
|
|
language plpgsql as $$
|
|
begin
|
|
if new.request_id='${orderNo("RETRY")}' then
|
|
raise exception 'forced_retry_grant_failure';
|
|
end if;
|
|
return new;
|
|
end $$;
|
|
create trigger test_fail_retry_grant before insert on public.credit_transactions
|
|
for each row execute function public.test_fail_retry_grant()
|
|
`);
|
|
const failedRetry = await serviceRuntime.query(
|
|
"select * from public.settle_order($1,$2,$3,$4)",
|
|
[orderNo("RETRY"), "trade-retry", 500, "hash-retry"],
|
|
);
|
|
assert.equal(failedRetry.rows[0].success, false);
|
|
assert.equal(failedRetry.rows[0].status, "grant_failed");
|
|
assert.equal(
|
|
sql(`select credits from public.profiles where id='${ids.retryUser}'`),
|
|
"0",
|
|
);
|
|
await admin.query(
|
|
"drop trigger test_fail_retry_grant on public.credit_transactions; drop function public.test_fail_retry_grant()",
|
|
);
|
|
|
|
const retry = await adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'retry_grant',0,$3,$4)",
|
|
[ids.billing, retryOrder, "重试失败权益发放", "retry-adjustment-1"],
|
|
);
|
|
assert.equal(retry.rows[0].action_success, true);
|
|
assert.equal(retry.rows[0].adjustment_version, 1);
|
|
assert.equal(
|
|
sql(`select credits from public.profiles where id='${ids.retryUser}'`),
|
|
"5",
|
|
);
|
|
const retryAgain = await adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'retry_grant',0,$3,$4)",
|
|
[ids.billing, retryOrder, "同一请求幂等重放", "retry-adjustment-1"],
|
|
);
|
|
assert.equal(retryAgain.rows[0].adjustment_version, 1);
|
|
assert.equal(
|
|
sql("select count(*) from audit.admin_audit_logs where request_id='retry-adjustment-1'"),
|
|
"1",
|
|
);
|
|
await assert.rejects(
|
|
adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'record_refund',0,$3,$4)",
|
|
[ids.billing, retryOrder, "过期版本不得退款", "refund-stale"],
|
|
),
|
|
(error) =>
|
|
(error as { code?: string }).code === "40001" &&
|
|
/admin_order_version_conflict/.test(errorText(error)),
|
|
);
|
|
|
|
const compensateOrder = await insertOrder({
|
|
suffix: "COMPENSATE",
|
|
userId: ids.compensateUser,
|
|
productId: ids.compensateProduct,
|
|
code: "compensate_credits",
|
|
price: 400,
|
|
credits: 4,
|
|
oneTime: false,
|
|
entitlements: [],
|
|
});
|
|
const invalidGrant = await serviceRuntime.query(
|
|
"select * from public.settle_order($1,$2,$3,$4)",
|
|
[orderNo("COMPENSATE"), "trade-compensate", 400, "hash-compensate"],
|
|
);
|
|
assert.equal(invalidGrant.rows[0].status, "invalid_snapshot");
|
|
await assert.rejects(
|
|
adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'compensate',0,'',$3)",
|
|
[ids.billing, compensateOrder, "compensate-empty-reason"],
|
|
),
|
|
(error) => /admin_reason_required/.test(errorText(error)),
|
|
);
|
|
await assert.rejects(
|
|
adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'compensate',0,$3,$4)",
|
|
[ids.support, compensateOrder, "无权人工补偿", "compensate-denied"],
|
|
),
|
|
(error) => /admin_permission_denied/.test(errorText(error)),
|
|
);
|
|
const compensated = await adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'compensate',0,$3,$4)",
|
|
[ids.billing, compensateOrder, "确认支付成功,人工补发积分", "compensate-1"],
|
|
);
|
|
assert.equal(compensated.rows[0].grant_status, "granted");
|
|
assert.equal(
|
|
sql(`select credits from public.profiles where id='${ids.compensateUser}'`),
|
|
"4",
|
|
);
|
|
assert.equal(
|
|
sql(`select count(*) from public.credit_transactions where user_id='${ids.compensateUser}' and transaction_type='compensation'`),
|
|
"1",
|
|
);
|
|
|
|
const balanceBeforeRefund = sql(
|
|
`select credits from public.profiles where id='${ids.retryUser}'`,
|
|
);
|
|
const refunded = await adminRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'record_refund',1,$3,$4)",
|
|
[ids.billing, retryOrder, "线下已退款,仅登记账务状态", "refund-record-1"],
|
|
);
|
|
assert.equal(refunded.rows[0].status, "refunded");
|
|
assert.equal(refunded.rows[0].refund_status, "recorded");
|
|
assert.equal(
|
|
sql(`select credits from public.profiles where id='${ids.retryUser}'`),
|
|
balanceBeforeRefund,
|
|
);
|
|
assert.equal(
|
|
sql(`select after_value->>'externalGatewayRefundAttempted' from audit.admin_audit_logs where request_id='refund-record-1'`),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql(`select permission_used||':'||reason from audit.admin_audit_logs where request_id='compensate-1'`),
|
|
"billing.adjustments.write:确认支付成功,人工补发积分",
|
|
);
|
|
|
|
assert.equal(
|
|
sql("select has_function_privilege('service_role','public.admin_create_redemption_codes(uuid,text,text,text,jsonb)','execute')"),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql("select has_function_privilege('service_role','public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz)','execute')"),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql("select has_function_privilege('service_role','public.admin_revoke_redemption_code(uuid,text,text,text,uuid)','execute')"),
|
|
"f",
|
|
);
|
|
for (const role of ["service_role", "service_runtime"]) {
|
|
assert.equal(
|
|
sql(`select has_function_privilege('${role}','public.admin_adjust_order(uuid,uuid,text,integer,text,text)','execute')`),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql(`select has_function_privilege('${role}','public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text)','execute')`),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql(`select has_function_privilege('${role}','public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text)','execute')`),
|
|
"f",
|
|
);
|
|
assert.equal(
|
|
sql(`select has_function_privilege('${role}','public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)','execute')`),
|
|
"f",
|
|
);
|
|
}
|
|
assert.equal(
|
|
sql("select pg_has_role('service_runtime','service_role','MEMBER')"),
|
|
"t",
|
|
);
|
|
assert.equal(
|
|
sql("select has_function_privilege('service_role','public.settle_order(text,text,integer,text)','execute')"),
|
|
"t",
|
|
);
|
|
for (const signature of [
|
|
"public.admin_adjust_order(uuid,uuid,text,integer,text,text)",
|
|
"public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text)",
|
|
"public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text)",
|
|
"public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)",
|
|
]) {
|
|
assert.equal(
|
|
sql(`select has_function_privilege('admin_runtime','${signature}','execute')`),
|
|
"t",
|
|
);
|
|
}
|
|
await assert.rejects(
|
|
serviceRuntime.query(
|
|
"select * from public.admin_adjust_order($1,$2,'record_refund',1,$3,$4)",
|
|
[ids.billing, retryOrder, "机器身份不得人工退款", "service-refund-denied"],
|
|
),
|
|
(error) => (error as { code?: string }).code === "42501",
|
|
);
|
|
await assert.rejects(
|
|
serviceRuntime.query(
|
|
"select * from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,$5)",
|
|
[ids.billing, "billing-adjust@example.com", "service-code-denied", "[]", "机器身份不得创建兑换码"],
|
|
),
|
|
(error) => (error as { code?: string }).code === "42501",
|
|
);
|
|
await assert.rejects(
|
|
adminRuntime.query(
|
|
"select * from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,'')",
|
|
[
|
|
ids.billing,
|
|
"billing-adjust@example.com",
|
|
"code-create-empty",
|
|
JSON.stringify([
|
|
{
|
|
codeHash: "a".repeat(64),
|
|
codeMask: "JYOTISH-****-AAAA",
|
|
credits: 3,
|
|
expiresAt: null,
|
|
note: "test",
|
|
},
|
|
]),
|
|
],
|
|
),
|
|
(error) => /admin_reason_required/.test(errorText(error)),
|
|
);
|
|
const createdCode = await adminRuntime.query<{ id: string }>(
|
|
"select id from public.admin_create_redemption_codes($1,$2,'admin',$3,$4::jsonb,$5)",
|
|
[
|
|
ids.billing,
|
|
"billing-adjust@example.com",
|
|
"code-create-1",
|
|
JSON.stringify([
|
|
{
|
|
codeHash: "b".repeat(64),
|
|
codeMask: "JYOTISH-****-BBBB",
|
|
credits: 3,
|
|
expiresAt: null,
|
|
note: "created",
|
|
},
|
|
]),
|
|
"创建客服补偿码",
|
|
],
|
|
);
|
|
const codeId = createdCode.rows[0].id;
|
|
await adminRuntime.query(
|
|
"select * from public.admin_update_redemption_code($1,$2,'admin',$3,$4,true,$5,false,null,$6)",
|
|
[
|
|
ids.billing,
|
|
"billing-adjust@example.com",
|
|
"code-update-1",
|
|
codeId,
|
|
"updated",
|
|
"修正兑换码备注",
|
|
],
|
|
);
|
|
await adminRuntime.query(
|
|
"select * from public.admin_revoke_redemption_code($1,$2,'admin',$3,$4,$5)",
|
|
[
|
|
ids.billing,
|
|
"billing-adjust@example.com",
|
|
"code-revoke-1",
|
|
codeId,
|
|
"撤销未发放兑换码",
|
|
],
|
|
);
|
|
assert.equal(
|
|
sql(`select string_agg(action||':'||permission_used||':'||reason,'|' order by created_at)
|
|
from audit.admin_audit_logs where target_id='${codeId}'`),
|
|
"redemption_code.create:billing.adjustments.write:创建客服补偿码|redemption_code.update:billing.adjustments.write:修正兑换码备注|redemption_code.revoke:billing.adjustments.write:撤销未发放兑换码",
|
|
);
|
|
} finally {
|
|
await Promise.all([
|
|
admin.end().catch(() => {}),
|
|
adminRuntime.end().catch(() => {}),
|
|
serviceRuntime.end().catch(() => {}),
|
|
]);
|
|
fixture.stop();
|
|
}
|
|
});
|