forked from wangziqi/gongxue-base
feat: enforce api production safety limits
This commit is contained in:
@@ -29,6 +29,8 @@ PORT=3000
|
|||||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||||
DEFAULT_TENANT_SLUG=master
|
DEFAULT_TENANT_SLUG=master
|
||||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
MAX_JSON_BODY_BYTES=1048576
|
||||||
|
MAX_IMPORT_JSON_BODY_BYTES=10485760
|
||||||
|
|
||||||
# 认证迁移期配置:生产环境必须替换为高强度随机值
|
# 认证迁移期配置:生产环境必须替换为高强度随机值
|
||||||
AUTH_SMS_PROVIDER=mock
|
AUTH_SMS_PROVIDER=mock
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ PORT=8787
|
|||||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||||
DEFAULT_TENANT_SLUG=master
|
DEFAULT_TENANT_SLUG=master
|
||||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
MAX_JSON_BODY_BYTES=1048576
|
||||||
|
MAX_IMPORT_JSON_BODY_BYTES=10485760
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ApiConfig {
|
|||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
defaultTenantSlug: string;
|
defaultTenantSlug: string;
|
||||||
corsOrigins: string[];
|
corsOrigins: string[];
|
||||||
|
maxJsonBodyBytes: number;
|
||||||
|
maxImportJsonBodyBytes: number;
|
||||||
authCodePepper: string;
|
authCodePepper: string;
|
||||||
authSessionSecret: string;
|
authSessionSecret: string;
|
||||||
authSmsProvider: string;
|
authSmsProvider: string;
|
||||||
@@ -38,21 +40,69 @@ export interface ApiConfig {
|
|||||||
|
|
||||||
loadDotenv();
|
loadDotenv();
|
||||||
|
|
||||||
const isProduction = envString('NODE_ENV', 'development') === 'production';
|
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
|
||||||
|
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
|
||||||
|
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
|
||||||
|
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
|
||||||
|
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
|
||||||
|
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
export const config: ApiConfig = {
|
function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BODY_BYTES) {
|
||||||
nodeEnv: envString('NODE_ENV', 'development'),
|
const value = envNumber(key, fallback);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||||
|
return Math.min(Math.trunc(value), hardMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnsafeSecret(value: string, defaultValue: string) {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
value === defaultValue ||
|
||||||
|
normalized.length < 32 ||
|
||||||
|
normalized.includes('replace_with') ||
|
||||||
|
normalized.includes('change-me') ||
|
||||||
|
normalized.includes('changeme')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProductionConfig(nextConfig: ApiConfig) {
|
||||||
|
if (!nextConfig.isProduction) return;
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
|
||||||
|
if (nextConfig.authSmsProvider === 'mock') failures.push('AUTH_SMS_PROVIDER=mock is not allowed in production');
|
||||||
|
if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) {
|
||||||
|
failures.push('AUTH_CODE_PEPPER must be a strong production secret');
|
||||||
|
}
|
||||||
|
if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) {
|
||||||
|
failures.push('AUTH_SESSION_SECRET must be a strong production secret');
|
||||||
|
}
|
||||||
|
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
|
||||||
|
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(`Invalid production API configuration: ${failures.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = envString('NODE_ENV', 'development');
|
||||||
|
const isProduction = nodeEnv === 'production';
|
||||||
|
|
||||||
|
const loadedConfig: ApiConfig = {
|
||||||
|
nodeEnv,
|
||||||
port: envNumber('PORT', 8787),
|
port: envNumber('PORT', 8787),
|
||||||
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
|
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
|
||||||
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
|
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
|
||||||
corsOrigins: envList('CORS_ORIGIN', '*'),
|
corsOrigins: envList('CORS_ORIGIN', '*'),
|
||||||
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
|
maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES),
|
||||||
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
|
maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES),
|
||||||
|
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
|
||||||
|
authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET),
|
||||||
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
|
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
|
||||||
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
||||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
|
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),
|
||||||
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
|
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
|
||||||
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
|
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
|
||||||
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
|
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
|
||||||
@@ -93,3 +143,7 @@ export const config: ApiConfig = {
|
|||||||
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
|
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
|
||||||
isProduction,
|
isProduction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
validateProductionConfig(loadedConfig);
|
||||||
|
|
||||||
|
export const config = loadedConfig;
|
||||||
|
|||||||
@@ -33,11 +33,28 @@ export function stringParam(ctx: RequestContext, name: string) {
|
|||||||
return ctx.url.searchParams.get(name)?.trim() || '';
|
return ctx.url.searchParams.get(name)?.trim() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonBody(ctx: RequestContext): Promise<JsonObject> {
|
export interface ReadJsonBodyOptions {
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise<JsonObject> {
|
||||||
|
const maxBytes = options.maxBytes ?? config.maxJsonBodyBytes;
|
||||||
|
const contentLength = Number(getHeader(ctx.req, 'content-length') || 0);
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||||
|
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');
|
||||||
|
}
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
for await (const chunk of ctx.req) {
|
for await (const chunk of ctx.req) {
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
totalBytes += buffer.length;
|
||||||
|
if (totalBytes > maxBytes) {
|
||||||
|
ctx.req.destroy();
|
||||||
|
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
|
import { config } from '../../core/config.js';
|
||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||||
import { query, queryOne, transaction } from '../../core/db.js';
|
import { query, queryOne, transaction } from '../../core/db.js';
|
||||||
@@ -2308,25 +2309,25 @@ async function runGenericImport<T>(
|
|||||||
|
|
||||||
export async function previewQuestionsImportRoute(ctx: RequestContext) {
|
export async function previewQuestionsImportRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
return createQuestionPreviewJob(auth, body);
|
return createQuestionPreviewJob(auth, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewVocabularyImportRoute(ctx: RequestContext) {
|
export async function previewVocabularyImportRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
return createGenericPreviewJob(auth, body, 'vocabulary', 'vocabulary_unit', createVocabularyNormalizedItems(body));
|
return createGenericPreviewJob(auth, body, 'vocabulary', 'vocabulary_unit', createVocabularyNormalizedItems(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewHandbookImportRoute(ctx: RequestContext) {
|
export async function previewHandbookImportRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
return createGenericPreviewJob(auth, body, 'handbook', 'handbook_subject', createHandbookNormalizedItems(body));
|
return createGenericPreviewJob(auth, body, 'handbook', 'handbook_subject', createHandbookNormalizedItems(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importQuestionsRoute(ctx: RequestContext) {
|
export async function importQuestionsRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
const allowPartial = boolValue(body.allowPartial, false);
|
const allowPartial = boolValue(body.allowPartial, false);
|
||||||
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
|
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
|
||||||
|
|
||||||
@@ -2441,7 +2442,7 @@ export async function importQuestionsRoute(ctx: RequestContext) {
|
|||||||
|
|
||||||
export async function importVocabularyRoute(ctx: RequestContext) {
|
export async function importVocabularyRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
return runGenericImport(
|
return runGenericImport(
|
||||||
auth,
|
auth,
|
||||||
body,
|
body,
|
||||||
@@ -2453,7 +2454,7 @@ export async function importVocabularyRoute(ctx: RequestContext) {
|
|||||||
|
|
||||||
export async function importHandbookRoute(ctx: RequestContext) {
|
export async function importHandbookRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(ctx);
|
const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes });
|
||||||
return runGenericImport(
|
return runGenericImport(
|
||||||
auth,
|
auth,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
|||||||
- 普通 JSON API 必须有默认上限。
|
- 普通 JSON API 必须有默认上限。
|
||||||
- 导入接口可以有更大上限,但必须可配置且有最大值。
|
- 导入接口可以有更大上限,但必须可配置且有最大值。
|
||||||
- 超限返回 413。
|
- 超限返回 413。
|
||||||
|
- 当前默认:`MAX_JSON_BODY_BYTES=1048576`,`MAX_IMPORT_JSON_BODY_BYTES=10485760`,硬上限 50MB。
|
||||||
|
|
||||||
6. 租户密钥保护
|
6. 租户密钥保护
|
||||||
- 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。
|
- 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
- 用 Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。
|
- 用 Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。
|
||||||
- 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界。
|
- 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界。
|
||||||
- 做一轮真实 JWT + RLS 回归测试。
|
- 做一轮真实 JWT + RLS 回归测试。
|
||||||
|
- 已补生产配置 fail-fast 和 JSON body size limit;后续继续补正式身份上下文。
|
||||||
|
|
||||||
2. 对象存储
|
2. 对象存储
|
||||||
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ async function startServerIfNeeded() {
|
|||||||
...process.env,
|
...process.env,
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||||
|
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
|
||||||
|
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -124,6 +126,56 @@ async function startServerIfNeeded() {
|
|||||||
await waitForHealth();
|
await waitForHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
reject(new Error('process did not exit before timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code, signal });
|
||||||
|
});
|
||||||
|
child.on('error', error => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProductionConfigFailFast() {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: String(port),
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||||
|
CORS_ORIGIN: '*',
|
||||||
|
AUTH_SMS_PROVIDER: 'mock',
|
||||||
|
AUTH_CODE_PEPPER: 'development-code-pepper-change-me',
|
||||||
|
AUTH_SESSION_SECRET: 'development-session-secret-change-me',
|
||||||
|
PLATFORM_ADMIN_API_KEY: 'local-platform-admin-key',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let logs = '';
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
logs += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => {
|
||||||
|
logs += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await waitForProcessExit(child);
|
||||||
|
assert.notEqual(result.code, 0, 'production server with unsafe defaults should fail to start');
|
||||||
|
assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config');
|
||||||
|
}
|
||||||
|
|
||||||
function stopServer() {
|
function stopServer() {
|
||||||
if (serverProcess && !serverProcess.killed) {
|
if (serverProcess && !serverProcess.killed) {
|
||||||
serverProcess.kill();
|
serverProcess.kill();
|
||||||
@@ -818,6 +870,38 @@ async function testTenantContentAssetsAndImports() {
|
|||||||
});
|
});
|
||||||
assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import');
|
assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import');
|
||||||
|
|
||||||
|
const oversizedNormalJson = await request('/api/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
phone: '13800000009',
|
||||||
|
purpose: 'login',
|
||||||
|
padding: 'x'.repeat(9000),
|
||||||
|
},
|
||||||
|
expectStatus: 413,
|
||||||
|
});
|
||||||
|
assert.equal(oversizedNormalJson.code, 'JSON_BODY_TOO_LARGE', 'ordinary JSON endpoints should enforce body size limit');
|
||||||
|
|
||||||
|
const largeImportPreview = await request('/api/tenant-content/imports/preview/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
sourceName: 'large-but-allowed-question-import.json',
|
||||||
|
subjectId: ids.subject,
|
||||||
|
categoryId: ids.category,
|
||||||
|
regionId: ids.region,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
legacyId: 'integration-large-preview-001',
|
||||||
|
type: 'choice',
|
||||||
|
content: `大体积导入预览:${'企业级导入需要受控上限。'.repeat(500)}`,
|
||||||
|
options: ['正确', '错误'],
|
||||||
|
correctOptionIndices: [0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(largeImportPreview.job?.errorCount, 0, 'import preview should use larger bounded body limit');
|
||||||
|
|
||||||
const invalidPreview = await request('/api/tenant-content/imports/preview/questions', {
|
const invalidPreview = await request('/api/tenant-content/imports/preview/questions', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1680,6 +1764,7 @@ async function testReferralAndCrmGrowth() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
|
await check('production config fail-fast', testProductionConfigFailFast);
|
||||||
await startServerIfNeeded();
|
await startServerIfNeeded();
|
||||||
console.log(`[INFO] API integration target: ${apiBase}`);
|
console.log(`[INFO] API integration target: ${apiBase}`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user