fix: harden platform form and response boundaries
This commit is contained in:
@@ -18,6 +18,16 @@ function loadResources() {
|
||||
return resourceModule.exports.resources;
|
||||
}
|
||||
|
||||
function loadResourceForm() {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/api/resource-form.ts'), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
return resourceModule.exports;
|
||||
}
|
||||
|
||||
test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => {
|
||||
const resources = loadResources();
|
||||
const field = (resource, key) => resources.find((item) => item.name === resource).fields.find((item) => item.key === key);
|
||||
@@ -30,19 +40,14 @@ test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => {
|
||||
test('表单载荷构造器省略空的可选关系并转换字段类型', async () => {
|
||||
const resourceFormPath = fromProjectRoot('src/api/resource-form.ts');
|
||||
assert.ok(fs.existsSync(resourceFormPath), 'resource-form.ts should define the payload boundary');
|
||||
const source = fs.readFileSync(resourceFormPath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
const resourceForm = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'gas_basic_identity', label: '气站', type: 'identity' },
|
||||
{ key: 'quantity', label: '数量', type: 'number' },
|
||||
{ key: 'selected', label: '选中', type: 'boolean' },
|
||||
];
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(resourceModule.exports.buildResourcePayload(fields, {
|
||||
JSON.parse(JSON.stringify(resourceForm.buildResourcePayload(fields, {
|
||||
gas_basic_identity: '',
|
||||
quantity: '2',
|
||||
selected: false,
|
||||
@@ -51,6 +56,81 @@ test('表单载荷构造器省略空的可选关系并转换字段类型', async
|
||||
);
|
||||
});
|
||||
|
||||
test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提交', () => {
|
||||
const resources = loadResources();
|
||||
const { buildResourcePayload } = loadResourceForm();
|
||||
const fields = (name) => resources.find((item) => item.name === name).fields;
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('saf_rule'), {
|
||||
rule_code: 'pressure-limit',
|
||||
threshold: '{"max":10}',
|
||||
action: 'close-valve',
|
||||
gray_scope: '["north"]',
|
||||
}))),
|
||||
{
|
||||
rule_code: 'pressure-limit',
|
||||
threshold: '{"max":10}',
|
||||
action: 'close-valve',
|
||||
gray_scope: '["north"]',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('ec_order_item'), {
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: '{"name":"液化气"}',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
}))),
|
||||
{
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: '{"name":"液化气"}',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => {
|
||||
const { buildResourcePayload, isResourceFieldRequired } = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'bill_date', label: '账单日期', type: 'date', required: true },
|
||||
{ key: 'started_at', label: '开始时间', type: 'datetime' },
|
||||
{ key: 'username', label: '用户名', type: 'text', required: true },
|
||||
{ key: 'password', label: '密码', type: 'password', required: true },
|
||||
];
|
||||
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'create'), true);
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'edit'), false);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
started_at: '2026-07-27T10:30:00Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
}, 'create'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
started_at: '2026-07-27T10:30:00.000Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
username: 'operator',
|
||||
password: 'replacement-must-not-be-sent',
|
||||
}, 'edit'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
username: 'operator',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('审批只读页提供同意和驳回操作', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8');
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import type { ResourceField } from './resources';
|
||||
|
||||
export type ResourceFormValue = string | number | boolean | undefined;
|
||||
export type ResourceFormMode = 'create' | 'edit';
|
||||
|
||||
export function isMissingField(value: ResourceFormValue | null): boolean {
|
||||
return value === '' || value === null || value === undefined;
|
||||
}
|
||||
|
||||
export function isResourceFieldRequired(
|
||||
field: ResourceField,
|
||||
mode: ResourceFormMode,
|
||||
): boolean {
|
||||
return Boolean(field.required && !(mode === 'edit' && field.type === 'password'));
|
||||
}
|
||||
|
||||
export function buildResourcePayload(
|
||||
fields: ResourceField[],
|
||||
form: Record<string, ResourceFormValue>,
|
||||
mode: ResourceFormMode = 'create',
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if (mode === 'edit' && field.type === 'password') continue;
|
||||
const value = form[field.key];
|
||||
if (isMissingField(value)) {
|
||||
if (!field.required) continue;
|
||||
@@ -30,9 +40,19 @@ export function buildResourcePayload(
|
||||
payload[field.key] = value === true || value === 'true';
|
||||
break;
|
||||
case 'json':
|
||||
payload[field.key] =
|
||||
typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (typeof value !== 'string')
|
||||
throw new Error(`${field.label}必须是有效 JSON`);
|
||||
JSON.parse(value);
|
||||
payload[field.key] = value;
|
||||
break;
|
||||
case 'date':
|
||||
case 'datetime': {
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime()))
|
||||
throw new Error(`${field.label}必须是有效日期`);
|
||||
payload[field.key] = date.toISOString();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
payload[field.key] = value;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新建${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<a-form-item v-for="field in formFields" :key="field.key" :label="field.label" :required="isResourceFieldRequired(field, formMode)">
|
||||
<a-switch v-if="field.type === 'boolean'" v-model="form[field.key]" />
|
||||
<a-input-number v-else-if="field.type === 'number'" v-model="form[field.key]" />
|
||||
<a-date-picker v-else-if="field.type === 'date'" v-model="form[field.key]" value-format="YYYY-MM-DD" />
|
||||
@@ -69,7 +69,11 @@
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import {
|
||||
buildResourcePayload,
|
||||
isMissingField,
|
||||
isResourceFieldRequired,
|
||||
} from '@/api/resource-form';
|
||||
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -91,6 +95,14 @@ const actionForm = reactive<Record<string, any>>({});
|
||||
const canCreate = computed(() => props.definition.mode !== 'readonly');
|
||||
const canEdit = computed(() => props.definition.mode === 'writable');
|
||||
const canArchive = computed(() => props.definition.mode === 'writable');
|
||||
const formMode = computed<'create' | 'edit'>(() =>
|
||||
editingIdentity.value ? 'edit' : 'create',
|
||||
);
|
||||
const formFields = computed(() =>
|
||||
props.definition.fields.filter(
|
||||
(field) => formMode.value === 'create' || field.type !== 'password',
|
||||
),
|
||||
);
|
||||
const displayFields = computed(() =>
|
||||
props.definition.fields.filter((field) => field.key !== 'status'),
|
||||
);
|
||||
@@ -190,15 +202,21 @@ async function submitDetailAction() {
|
||||
|
||||
async function save() {
|
||||
if (
|
||||
props.definition.fields.some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
formFields.value.some(
|
||||
(field) =>
|
||||
isResourceFieldRequired(field, formMode.value) &&
|
||||
isMissingField(form[field.key]),
|
||||
)
|
||||
) {
|
||||
Message.warning('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = buildResourcePayload(props.definition.fields, form);
|
||||
const payload = buildResourcePayload(
|
||||
props.definition.fields,
|
||||
form,
|
||||
formMode.value,
|
||||
);
|
||||
if (editingIdentity.value)
|
||||
await resourceApi.update(
|
||||
props.definition.resource,
|
||||
|
||||
Reference in New Issue
Block a user