From 8c3be1a613e345f2f5c0aec829a5d132f15bd76e Mon Sep 17 00:00:00 2001 From: Cita Date: Wed, 1 Apr 2026 12:52:55 +0800 Subject: [PATCH] Add zod payload contracts for admin API routes (#358) * Add zod route payload contracts for admin APIs * Fix review follow-ups for zod admin payloads * Refactor account payload parser helpers --- package-lock.json | 12 +- package.json | 3 +- .../contracts/accountTokensRoutePayloads.ts | 146 +++++++++++++ src/server/contracts/accountsRoutePayloads.ts | 196 +++++++++++++++++ .../downstreamApiKeyRoutePayloads.ts | 119 ++++++++++ src/server/contracts/settingsRoutePayloads.ts | 204 ++++++++++++++++++ src/server/contracts/siteRoutePayloads.ts | 157 ++++++++++++++ src/server/contracts/supportRoutePayloads.ts | 191 ++++++++++++++++ src/server/contracts/tokenRoutePayloads.ts | 193 +++++++++++++++++ .../routes/api/accountTokens.batch.test.ts | 14 ++ .../routes/api/accountTokens.sync.test.ts | 134 ++++++++++++ src/server/routes/api/accountTokens.ts | 79 ++++--- src/server/routes/api/accounts.batch.test.ts | 14 ++ .../api/accounts.credentialMode.test.ts | 96 +++++++++ ...accounts.healthRefreshRuntimeState.test.ts | 22 ++ .../routes/api/accounts.login-shield.test.ts | 18 ++ .../routes/api/accounts.manual-models.test.ts | 26 +++ .../api/accounts.rebind-session.test.ts | 27 +++ src/server/routes/api/accounts.ts | 104 ++++++--- src/server/routes/api/auth.test.ts | 66 ++++++ src/server/routes/api/auth.ts | 10 +- .../routes/api/downstreamApiKeys.test.ts | 55 +++++ src/server/routes/api/downstreamApiKeys.ts | 104 ++++----- src/server/routes/api/monitor.test.ts | 53 +++++ src/server/routes/api/monitor.ts | 10 +- src/server/routes/api/oauth.test.ts | 49 +++++ src/server/routes/api/oauth.ts | 45 ++-- .../routes/api/settings.backup-webdav.test.ts | 89 ++++++-- .../api/settings.databaseMigration.test.ts | 17 +- .../api/settings.databaseRuntime.test.ts | 15 ++ src/server/routes/api/settings.events.test.ts | 29 +++ .../api/settings.system-proxy-test.test.ts | 26 +++ src/server/routes/api/settings.ts | 98 +++++++-- src/server/routes/api/sites.batch.test.ts | 16 ++ .../routes/api/sites.disabledModels.test.ts | 16 ++ src/server/routes/api/sites.proxyUrl.test.ts | 128 +++++------ src/server/routes/api/sites.ts | 144 ++++++------- src/server/routes/api/tokens.batch.test.ts | 63 +++--- .../api/tokens.route-update-rebuild.test.ts | 144 +++++++++++++ src/server/routes/api/tokens.ts | 120 +++++++---- src/server/routes/api/updateCenter.test.ts | 69 ++++++ src/server/routes/api/updateCenter.ts | 46 +++- src/server/services/siteProxy.ts | 2 +- 43 files changed, 2765 insertions(+), 404 deletions(-) create mode 100644 src/server/contracts/accountTokensRoutePayloads.ts create mode 100644 src/server/contracts/accountsRoutePayloads.ts create mode 100644 src/server/contracts/downstreamApiKeyRoutePayloads.ts create mode 100644 src/server/contracts/settingsRoutePayloads.ts create mode 100644 src/server/contracts/siteRoutePayloads.ts create mode 100644 src/server/contracts/supportRoutePayloads.ts create mode 100644 src/server/contracts/tokenRoutePayloads.ts create mode 100644 src/server/routes/api/auth.test.ts create mode 100644 src/server/routes/api/monitor.test.ts diff --git a/package-lock.json b/package-lock.json index 60ff1e3..6d7a4d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "pg": "^8.16.3", "socks": "^2.8.7", "undici": "^6.24.1", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.3.6" }, "devDependencies": { "@electron/notarize": "^3.1.0", @@ -17177,6 +17178,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index a540bc2..fc7c091 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,8 @@ "pg": "^8.16.3", "socks": "^2.8.7", "undici": "^6.24.1", - "ws": "^8.20.0" + "ws": "^8.20.0", + "zod": "^4.3.6" }, "devDependencies": { "@electron/notarize": "^3.1.0", diff --git a/src/server/contracts/accountTokensRoutePayloads.ts b/src/server/contracts/accountTokensRoutePayloads.ts new file mode 100644 index 0000000..f17a6c8 --- /dev/null +++ b/src/server/contracts/accountTokensRoutePayloads.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +const accountTokenCreatePayloadSchema = z.object({ + accountId: z.number().int().positive(), + name: z.string().optional(), + token: z.string().optional(), + enabled: z.boolean().optional(), + isDefault: z.boolean().optional(), + source: z.string().optional(), + group: z.string().optional(), + unlimitedQuota: z.boolean().optional(), + remainQuota: z.union([z.number(), z.string()]).optional(), + expiredTime: z.union([z.number(), z.string()]).optional(), + allowIps: z.string().optional(), + modelLimitsEnabled: z.boolean().optional(), + modelLimits: z.string().optional(), +}).passthrough(); + +const accountTokenBatchPayloadSchema = z.object({ + ids: z.array(z.number().int().positive()).optional(), + action: z.string().optional(), +}).passthrough(); + +const accountTokenUpdatePayloadSchema = z.object({ + name: z.string().optional(), + token: z.string().optional(), + group: z.string().optional(), + enabled: z.boolean().optional(), + isDefault: z.boolean().optional(), + source: z.string().optional(), +}).passthrough(); + +const accountTokenSyncAllPayloadSchema = z.object({ + wait: z.boolean().optional(), +}).passthrough(); + +export type AccountTokenBatchPayload = z.output; +export type AccountTokenCreatePayload = z.output; +export type AccountTokenSyncAllPayload = z.output; +export type AccountTokenUpdatePayload = z.output; + +function normalizeAccountTokenPayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatAccountTokenPayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const firstPath = firstIssue?.path[0]; + if (firstPath === 'accountId') { + return 'Invalid accountId. Expected positive number.'; + } + if (firstPath === 'token') { + return 'Invalid token. Expected string.'; + } + if (firstPath === 'name') { + return 'Invalid name. Expected string.'; + } + if (firstPath === 'enabled') { + return 'Invalid enabled. Expected boolean.'; + } + if (firstPath === 'isDefault') { + return 'Invalid isDefault. Expected boolean.'; + } + if (firstPath === 'source') { + return 'Invalid source. Expected string.'; + } + if (firstPath === 'group') { + return 'Invalid group. Expected string.'; + } + if (firstPath === 'unlimitedQuota') { + return 'Invalid unlimitedQuota. Expected boolean.'; + } + if (firstPath === 'modelLimitsEnabled') { + return 'Invalid modelLimitsEnabled. Expected boolean.'; + } + if (firstPath === 'ids') { + return 'Invalid ids. Expected number[].'; + } + if (firstPath === 'action') { + return 'Invalid action. Expected string.'; + } + if (firstPath === 'wait') { + return 'Invalid wait. Expected boolean.'; + } + return 'Invalid account token payload.'; +} + +export function parseAccountTokenCreatePayload(input: unknown): +{ success: true; data: AccountTokenCreatePayload } | { success: false; error: string } { + const result = accountTokenCreatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatAccountTokenPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseAccountTokenBatchPayload(input: unknown): +{ success: true; data: AccountTokenBatchPayload } | { success: false; error: string } { + const result = accountTokenBatchPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatAccountTokenPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseAccountTokenUpdatePayload(input: unknown): +{ success: true; data: AccountTokenUpdatePayload } | { success: false; error: string } { + const result = accountTokenUpdatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatAccountTokenPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseAccountTokenSyncAllPayload(input: unknown): +{ success: true; data: AccountTokenSyncAllPayload } | { success: false; error: string } { + const result = accountTokenSyncAllPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatAccountTokenPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} diff --git a/src/server/contracts/accountsRoutePayloads.ts b/src/server/contracts/accountsRoutePayloads.ts new file mode 100644 index 0000000..134f36c --- /dev/null +++ b/src/server/contracts/accountsRoutePayloads.ts @@ -0,0 +1,196 @@ +import { z } from 'zod'; + +const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']); + +const accountCreatePayloadSchema = z.object({ + siteId: z.number().int().positive(), + username: z.string().optional(), + accessToken: z.string(), + apiToken: z.string().optional(), + platformUserId: z.number().int().positive().optional(), + checkinEnabled: z.boolean().optional(), + credentialMode: accountCredentialModeSchema.optional(), + refreshToken: z.string().optional(), + tokenExpiresAt: z.union([z.number(), z.string()]).optional(), + skipModelFetch: z.boolean().optional(), +}).passthrough(); + +const accountUpdatePayloadSchema = z.object({ + username: z.string().optional(), + accessToken: z.string().optional(), + apiToken: z.union([z.string(), z.null()]).optional(), + status: z.string().optional(), + checkinEnabled: z.boolean().optional(), + unitCost: z.number().optional(), + extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(), + refreshToken: z.string().optional(), + tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(), + isPinned: z.boolean().optional(), + sortOrder: z.number().int().min(0).optional(), + proxyUrl: z.union([z.string(), z.null()]).optional(), +}).passthrough(); + +const accountBatchPayloadSchema = z.object({ + ids: z.array(z.number().int().positive()).optional(), + action: z.string().optional(), +}).passthrough(); + +const accountRebindSessionPayloadSchema = z.object({ + accessToken: z.string().optional(), + platformUserId: z.number().int().positive().optional(), + refreshToken: z.string().optional(), + tokenExpiresAt: z.union([z.number(), z.string()]).optional(), +}).passthrough(); + +const accountHealthRefreshPayloadSchema = z.object({ + accountId: z.number().int().positive().optional(), + wait: z.boolean().optional(), +}).passthrough(); + +const accountLoginPayloadSchema = z.object({ + siteId: z.number().int().positive(), + username: z.string(), + password: z.string(), +}).passthrough(); + +const accountVerifyTokenPayloadSchema = z.object({ + siteId: z.number().int().positive(), + accessToken: z.string().optional(), + platformUserId: z.number().int().positive().optional(), + credentialMode: accountCredentialModeSchema.optional(), +}).passthrough(); + +const accountManualModelsPayloadSchema = z.object({ + models: z.array(z.string()).optional(), +}).passthrough(); + +export type AccountBatchPayload = z.output; +export type AccountCreatePayload = z.output; +export type AccountHealthRefreshPayload = z.output; +export type AccountLoginPayload = z.output; +export type AccountManualModelsPayload = z.output; +export type AccountRebindSessionPayload = z.output; +export type AccountUpdatePayload = z.output; +export type AccountVerifyTokenPayload = z.output; + +function normalizeAccountsPayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatAccountsPayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const firstPath = firstIssue?.path[0]; + if (firstPath === 'siteId') { + return 'Invalid siteId. Expected positive number.'; + } + if (firstPath === 'accessToken') { + return 'Invalid accessToken. Expected string.'; + } + if (firstPath === 'username') { + return 'Invalid username. Expected string.'; + } + if (firstPath === 'password') { + return 'Invalid password. Expected string.'; + } + if (firstPath === 'apiToken') { + return 'Invalid apiToken. Expected string or null.'; + } + if (firstPath === 'checkinEnabled') { + return 'Invalid checkinEnabled. Expected boolean.'; + } + if (firstPath === 'credentialMode') { + return 'Invalid credentialMode. Expected auto/session/apikey.'; + } + if (firstPath === 'skipModelFetch') { + return 'Invalid skipModelFetch. Expected boolean.'; + } + if (firstPath === 'isPinned') { + return 'Invalid isPinned. Expected boolean.'; + } + if (firstPath === 'sortOrder') { + return 'Invalid sortOrder. Expected non-negative integer.'; + } + if (firstPath === 'proxyUrl') { + return 'Invalid proxyUrl. Expected string or null.'; + } + if (firstPath === 'ids') { + return 'Invalid ids. Expected number[].'; + } + if (firstPath === 'action') { + return 'Invalid action. Expected string.'; + } + if (firstPath === 'platformUserId') { + return 'Invalid platformUserId. Expected positive number.'; + } + if (firstPath === 'refreshToken') { + return 'Invalid refreshToken. Expected string.'; + } + if (firstPath === 'tokenExpiresAt') { + return 'Invalid tokenExpiresAt. Expected number or string.'; + } + if (firstPath === 'accountId') { + return '账号 ID 无效'; + } + if (firstPath === 'wait') { + return 'Invalid wait. Expected boolean.'; + } + if (firstPath === 'models') { + return 'Invalid models. Expected string[].'; + } + return 'Invalid account payload.'; +} + +function parseAccountsPayload(schema: z.ZodType, input: unknown): +{ success: true; data: T } | { success: false; error: string } { + const result = schema.safeParse(normalizeAccountsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatAccountsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseAccountCreatePayload(input: unknown): +{ success: true; data: AccountCreatePayload } | { success: false; error: string } { + return parseAccountsPayload(accountCreatePayloadSchema, input); +} + +export function parseAccountUpdatePayload(input: unknown): +{ success: true; data: AccountUpdatePayload } | { success: false; error: string } { + return parseAccountsPayload(accountUpdatePayloadSchema, input); +} + +export function parseAccountBatchPayload(input: unknown): +{ success: true; data: AccountBatchPayload } | { success: false; error: string } { + return parseAccountsPayload(accountBatchPayloadSchema, input); +} + +export function parseAccountRebindSessionPayload(input: unknown): +{ success: true; data: AccountRebindSessionPayload } | { success: false; error: string } { + return parseAccountsPayload(accountRebindSessionPayloadSchema, input); +} + +export function parseAccountHealthRefreshPayload(input: unknown): +{ success: true; data: AccountHealthRefreshPayload } | { success: false; error: string } { + return parseAccountsPayload(accountHealthRefreshPayloadSchema, input); +} + +export function parseAccountLoginPayload(input: unknown): +{ success: true; data: AccountLoginPayload } | { success: false; error: string } { + return parseAccountsPayload(accountLoginPayloadSchema, input); +} + +export function parseAccountVerifyTokenPayload(input: unknown): +{ success: true; data: AccountVerifyTokenPayload } | { success: false; error: string } { + return parseAccountsPayload(accountVerifyTokenPayloadSchema, input); +} + +export function parseAccountManualModelsPayload(input: unknown): +{ success: true; data: AccountManualModelsPayload } | { success: false; error: string } { + return parseAccountsPayload(accountManualModelsPayloadSchema, input); +} diff --git a/src/server/contracts/downstreamApiKeyRoutePayloads.ts b/src/server/contracts/downstreamApiKeyRoutePayloads.ts new file mode 100644 index 0000000..b3b6a59 --- /dev/null +++ b/src/server/contracts/downstreamApiKeyRoutePayloads.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; + +const downstreamApiKeyPayloadSchema = z.object({ + name: z.string().optional(), + key: z.string().optional(), + description: z.union([z.string(), z.null()]).optional(), + groupName: z.union([z.string(), z.null()]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), + enabled: z.boolean().optional(), + expiresAt: z.union([z.string(), z.null()]).optional(), + maxCost: z.union([z.number(), z.string(), z.null()]).optional(), + maxRequests: z.union([z.number(), z.string(), z.null()]).optional(), + supportedModels: z.union([z.string(), z.array(z.string())]).optional(), + allowedRouteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(), + siteWeightMultipliers: z.union([ + z.string(), + z.record(z.string(), z.union([z.number(), z.string()])), + ]).optional(), +}).passthrough(); + +const downstreamApiKeyBatchPayloadSchema = z.object({ + ids: z.array(z.number().int().positive()).optional(), + action: z.string().optional(), + groupOperation: z.string().optional(), + groupName: z.union([z.string(), z.null()]).optional(), + tagOperation: z.string().optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), +}).passthrough(); + +export type DownstreamApiKeyBatchPayload = z.output; +export type DownstreamApiKeyPayload = z.output; + +function normalizeDownstreamApiKeyPayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatDownstreamApiKeyPayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const [firstPath] = firstIssue?.path ?? []; + if (!firstPath) { + return '参数无效:请求体必须是对象'; + } + if (firstPath === 'name') { + return 'Invalid name. Expected string.'; + } + if (firstPath === 'key') { + return 'Invalid key. Expected string.'; + } + if (firstPath === 'description') { + return 'Invalid description. Expected string or null.'; + } + if (firstPath === 'groupName') { + return 'Invalid groupName. Expected string or null.'; + } + if (firstPath === 'tags') { + return 'Invalid tags. Expected string or string[].'; + } + if (firstPath === 'enabled') { + return 'Invalid enabled. Expected boolean.'; + } + if (firstPath === 'expiresAt') { + return 'Invalid expiresAt. Expected string or null.'; + } + if (firstPath === 'maxCost') { + return 'Invalid maxCost. Expected number, string, or null.'; + } + if (firstPath === 'maxRequests') { + return 'Invalid maxRequests. Expected number, string, or null.'; + } + if (firstPath === 'supportedModels') { + return 'Invalid supportedModels. Expected string or string[].'; + } + if (firstPath === 'allowedRouteIds') { + return 'Invalid allowedRouteIds. Expected string or array.'; + } + if (firstPath === 'siteWeightMultipliers') { + return 'Invalid siteWeightMultipliers. Expected JSON object or string.'; + } + if (firstPath === 'ids') { + return 'Invalid ids. Expected number[].'; + } + if (firstPath === 'action') { + return 'Invalid action. Expected string.'; + } + if (firstPath === 'groupOperation') { + return 'Invalid groupOperation. Expected string.'; + } + if (firstPath === 'tagOperation') { + return 'Invalid tagOperation. Expected string.'; + } + return '参数无效'; +} + +function parseDownstreamApiKeyRoutePayload( + schema: T, + input: unknown, +): { success: true; data: z.output } | { success: false; error: string } { + const result = schema.safeParse(normalizeDownstreamApiKeyPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatDownstreamApiKeyPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseDownstreamApiKeyPayload(input: unknown): +{ success: true; data: DownstreamApiKeyPayload } | { success: false; error: string } { + return parseDownstreamApiKeyRoutePayload(downstreamApiKeyPayloadSchema, input); +} + +export function parseDownstreamApiKeyBatchPayload(input: unknown): +{ success: true; data: DownstreamApiKeyBatchPayload } | { success: false; error: string } { + return parseDownstreamApiKeyRoutePayload(downstreamApiKeyBatchPayloadSchema, input); +} diff --git a/src/server/contracts/settingsRoutePayloads.ts b/src/server/contracts/settingsRoutePayloads.ts new file mode 100644 index 0000000..df6ff08 --- /dev/null +++ b/src/server/contracts/settingsRoutePayloads.ts @@ -0,0 +1,204 @@ +import { z } from 'zod'; + +const backupExportTypeSchema = z.enum(['all', 'accounts', 'preferences']); +const migrationDialectSchema = z.enum(['sqlite', 'mysql', 'postgres']); + +const runtimeSettingsPayloadSchema = z.object({ + webhookEnabled: z.boolean().optional(), + barkEnabled: z.boolean().optional(), + serverChanEnabled: z.boolean().optional(), + telegramEnabled: z.boolean().optional(), + telegramUseSystemProxy: z.boolean().optional(), + smtpEnabled: z.boolean().optional(), + smtpSecure: z.boolean().optional(), + logCleanupUsageLogsEnabled: z.boolean().optional(), + logCleanupProgramLogsEnabled: z.boolean().optional(), +}).passthrough(); + +const systemProxyTestPayloadSchema = z.object({ + proxyUrl: z.string().optional(), +}).passthrough(); + +const databaseMigrationPayloadSchema = z.object({ + dialect: migrationDialectSchema, + connectionString: z.string().trim().min(1), + overwrite: z.boolean().optional(), + ssl: z.boolean().optional(), +}).passthrough(); + +const backupWebdavConfigPayloadSchema = z.object({ + enabled: z.boolean().optional(), + fileUrl: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + clearPassword: z.boolean().optional(), + exportType: backupExportTypeSchema.optional(), + autoSyncEnabled: z.boolean().optional(), + autoSyncCron: z.string().optional(), +}).passthrough(); + +const backupWebdavExportPayloadSchema = z.object({ + type: backupExportTypeSchema.optional(), +}).passthrough(); + +const backupImportPayloadSchema = z.object({ + data: z.record(z.string(), z.unknown()), +}).passthrough(); + +export type BackupWebdavConfigPayload = z.output; +export type BackupWebdavExportPayload = z.output; +export type BackupImportPayload = z.output; +export type DatabaseMigrationPayload = z.output; +export type RuntimeSettingsPayload = z.output; +export type SystemProxyTestPayload = z.output; + +function normalizeSettingsPayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatSettingsPayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const firstPath = firstIssue?.path[0]; + if (firstPath === 'exportType') { + return 'Invalid exportType. Expected all/accounts/preferences.'; + } + if (firstPath === 'type') { + return 'Invalid type. Expected all/accounts/preferences.'; + } + if (firstPath === 'proxyUrl') { + return '系统代理地址格式无效:需要 string'; + } + if (firstPath === 'dialect') { + return 'Invalid dialect. Expected sqlite/mysql/postgres.'; + } + if (firstPath === 'connectionString') { + return 'Invalid connectionString. Expected non-empty string.'; + } + if (firstPath === 'overwrite') { + return 'Invalid overwrite. Expected boolean.'; + } + if (firstPath === 'ssl') { + return 'Invalid ssl. Expected boolean.'; + } + if (firstPath === 'data') { + return '导入数据格式错误:需要 JSON 对象'; + } + if (firstPath === 'webhookEnabled') { + return 'Webhook 开关格式无效:需要 boolean'; + } + if (firstPath === 'barkEnabled') { + return 'Bark 开关格式无效:需要 boolean'; + } + if (firstPath === 'serverChanEnabled') { + return 'Server 酱开关格式无效:需要 boolean'; + } + if (firstPath === 'telegramEnabled') { + return 'Telegram 开关格式无效:需要 boolean'; + } + if (firstPath === 'telegramUseSystemProxy') { + return 'Telegram 使用系统代理格式无效:需要 boolean'; + } + if (firstPath === 'smtpEnabled') { + return 'SMTP 开关格式无效:需要 boolean'; + } + if (firstPath === 'smtpSecure') { + return 'SMTP 安全连接格式无效:需要 boolean'; + } + if (firstPath === 'logCleanupUsageLogsEnabled') { + return '自动清理使用日志格式无效:需要 boolean'; + } + if (firstPath === 'logCleanupProgramLogsEnabled') { + return '自动清理程序日志格式无效:需要 boolean'; + } + return 'Invalid settings payload.'; +} + +export function parseRuntimeSettingsPayload(input: unknown): +{ success: true; data: RuntimeSettingsPayload } | { success: false; error: string } { + const result = runtimeSettingsPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseSystemProxyTestPayload(input: unknown): +{ success: true; data: SystemProxyTestPayload } | { success: false; error: string } { + const result = systemProxyTestPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseDatabaseMigrationPayload(input: unknown): +{ success: true; data: DatabaseMigrationPayload } | { success: false; error: string } { + const result = databaseMigrationPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseBackupWebdavConfigPayload(input: unknown): +{ success: true; data: BackupWebdavConfigPayload } | { success: false; error: string } { + const result = backupWebdavConfigPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseBackupImportPayload(input: unknown): +{ success: true; data: BackupImportPayload } | { success: false; error: string } { + const result = backupImportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseBackupWebdavExportPayload(input: unknown): +{ success: true; data: BackupWebdavExportPayload } | { success: false; error: string } { + const result = backupWebdavExportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSettingsPayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} diff --git a/src/server/contracts/siteRoutePayloads.ts b/src/server/contracts/siteRoutePayloads.ts new file mode 100644 index 0000000..d6b01d6 --- /dev/null +++ b/src/server/contracts/siteRoutePayloads.ts @@ -0,0 +1,157 @@ +import { z } from 'zod'; + +const requiredTrimmedString = z.string().trim().min(1); +const unknownField = z.unknown().optional(); + +const siteCreatePayloadSchema = z.object({ + name: requiredTrimmedString, + url: requiredTrimmedString, + platform: z.string().trim().optional(), + initializationPresetId: z.union([z.string(), z.null()]).optional(), + proxyUrl: unknownField, + useSystemProxy: unknownField, + customHeaders: unknownField, + externalCheckinUrl: unknownField, + status: unknownField, + isPinned: unknownField, + sortOrder: unknownField, + globalWeight: unknownField, +}).passthrough(); + +const siteUpdatePayloadSchema = z.object({ + name: requiredTrimmedString.optional(), + url: requiredTrimmedString.optional(), + platform: requiredTrimmedString.optional(), + proxyUrl: unknownField, + useSystemProxy: unknownField, + customHeaders: unknownField, + externalCheckinUrl: unknownField, + status: unknownField, + isPinned: unknownField, + sortOrder: unknownField, + globalWeight: unknownField, +}).passthrough(); + +const siteBatchPayloadSchema = z.object({ + ids: z.array(z.number().int().positive()).optional(), + action: z.string().optional(), +}).passthrough(); + +const siteDisabledModelsPayloadSchema = z.object({ + models: z.array(z.string()).optional(), +}).passthrough(); + +const siteDetectPayloadSchema = z.object({ + url: requiredTrimmedString, +}).passthrough(); + +export type SiteBatchPayload = z.output; +export type SiteCreatePayload = z.output; +export type SiteDetectPayload = z.output; +export type SiteDisabledModelsPayload = z.output; +export type SiteUpdatePayload = z.output; + +function normalizeSitePayloadInput(input: unknown): unknown { + if (input === undefined) return {}; + if (input && typeof input === 'object' && !Array.isArray(input)) return input; + return null; +} + +function formatSitePayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const firstPath = firstIssue?.path[0]; + if (firstPath === 'name') { + return 'Invalid name. Expected non-empty string.'; + } + if (firstPath === 'url') { + return 'Invalid url. Expected non-empty string.'; + } + if (firstPath === 'platform') { + return 'Invalid platform. Expected string.'; + } + if (firstPath === 'ids') { + return 'Invalid ids. Expected number[].'; + } + if (firstPath === 'action') { + return 'Invalid action. Expected string.'; + } + if (firstPath === 'models') { + return 'Invalid models. Expected string[].'; + } + return 'Invalid site payload.'; +} + +export function parseSiteCreatePayload(input: unknown): +{ success: true; data: SiteCreatePayload } | { success: false; error: string } { + const result = siteCreatePayloadSchema.safeParse(normalizeSitePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSitePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseSiteUpdatePayload(input: unknown): +{ success: true; data: SiteUpdatePayload } | { success: false; error: string } { + const result = siteUpdatePayloadSchema.safeParse(normalizeSitePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSitePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseSiteBatchPayload(input: unknown): +{ success: true; data: SiteBatchPayload } | { success: false; error: string } { + const result = siteBatchPayloadSchema.safeParse(normalizeSitePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSitePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseSiteDisabledModelsPayload(input: unknown): +{ success: true; data: SiteDisabledModelsPayload } | { success: false; error: string } { + const result = siteDisabledModelsPayloadSchema.safeParse(normalizeSitePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSitePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseSiteDetectPayload(input: unknown): +{ success: true; data: SiteDetectPayload } | { success: false; error: string } { + const result = siteDetectPayloadSchema.safeParse(normalizeSitePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSitePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} diff --git a/src/server/contracts/supportRoutePayloads.ts b/src/server/contracts/supportRoutePayloads.ts new file mode 100644 index 0000000..36fa60b --- /dev/null +++ b/src/server/contracts/supportRoutePayloads.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; + +const updateCenterVersionSourceSchema = z.enum(['docker-hub-tag', 'github-release']); + +const authChangePayloadSchema = z.object({ + oldToken: z.string().optional(), + newToken: z.string().optional(), +}).passthrough(); + +const monitorConfigPayloadSchema = z.object({ + ldohCookie: z.union([z.string(), z.null()]).optional(), +}).passthrough(); + +const oauthStartPayloadSchema = z.object({ + accountId: z.number().int().positive().optional(), + projectId: z.string().optional(), + proxyUrl: z.union([z.string(), z.null()]).optional(), +}).passthrough(); + +const oauthManualCallbackPayloadSchema = z.object({ + callbackUrl: z.string().optional(), +}).passthrough(); + +const oauthConnectionRebindPayloadSchema = z.object({ + proxyUrl: z.union([z.string(), z.null()]).optional(), +}).passthrough(); + +const updateCenterConfigPayloadSchema = z.object({ + enabled: z.boolean().optional(), + helperBaseUrl: z.string().optional(), + namespace: z.string().optional(), + releaseName: z.string().optional(), + chartRef: z.string().optional(), + imageRepository: z.string().optional(), + githubReleasesEnabled: z.boolean().optional(), + dockerHubTagsEnabled: z.boolean().optional(), + defaultDeploySource: updateCenterVersionSourceSchema.optional(), +}).passthrough(); + +const updateCenterDeployPayloadSchema = z.object({ + source: updateCenterVersionSourceSchema.optional(), + targetVersion: z.string().optional(), + targetTag: z.string().optional(), + targetDigest: z.string().optional(), +}).passthrough(); + +const updateCenterRollbackPayloadSchema = z.object({ + targetRevision: z.string().optional(), +}).passthrough(); + +export type AuthChangePayload = z.output; +export type MonitorConfigPayload = z.output; +export type OauthConnectionRebindPayload = z.output; +export type OauthManualCallbackPayload = z.output; +export type OauthStartPayload = z.output; +export type UpdateCenterConfigPayload = z.output; +export type UpdateCenterDeployPayload = z.output; +export type UpdateCenterRollbackPayload = z.output; + +function normalizeSupportRoutePayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatSupportRoutePayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const [firstPath] = firstIssue?.path ?? []; + if (!firstPath) { + return '请求体必须是对象'; + } + if (firstPath === 'oldToken') { + return 'Invalid oldToken. Expected string.'; + } + if (firstPath === 'newToken') { + return 'Invalid newToken. Expected string.'; + } + if (firstPath === 'ldohCookie') { + return 'Invalid ldohCookie. Expected string or null.'; + } + if (firstPath === 'accountId') { + return 'Invalid accountId. Expected positive number.'; + } + if (firstPath === 'projectId') { + return 'Invalid projectId. Expected string.'; + } + if (firstPath === 'proxyUrl') { + return 'Invalid proxyUrl. Expected string or null.'; + } + if (firstPath === 'callbackUrl') { + return 'Invalid callbackUrl. Expected string.'; + } + if (firstPath === 'enabled') { + return 'Invalid enabled. Expected boolean.'; + } + if (firstPath === 'helperBaseUrl') { + return 'Invalid helperBaseUrl. Expected string.'; + } + if (firstPath === 'namespace') { + return 'Invalid namespace. Expected string.'; + } + if (firstPath === 'releaseName') { + return 'Invalid releaseName. Expected string.'; + } + if (firstPath === 'chartRef') { + return 'Invalid chartRef. Expected string.'; + } + if (firstPath === 'imageRepository') { + return 'Invalid imageRepository. Expected string.'; + } + if (firstPath === 'githubReleasesEnabled') { + return 'Invalid githubReleasesEnabled. Expected boolean.'; + } + if (firstPath === 'dockerHubTagsEnabled') { + return 'Invalid dockerHubTagsEnabled. Expected boolean.'; + } + if (firstPath === 'defaultDeploySource') { + return 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.'; + } + if (firstPath === 'source') { + return 'Invalid source. Expected docker-hub-tag/github-release.'; + } + if (firstPath === 'targetVersion') { + return 'Invalid targetVersion. Expected string.'; + } + if (firstPath === 'targetTag') { + return 'Invalid targetTag. Expected string.'; + } + if (firstPath === 'targetDigest') { + return 'Invalid targetDigest. Expected string.'; + } + if (firstPath === 'targetRevision') { + return 'Invalid targetRevision. Expected string.'; + } + return 'Invalid support route payload.'; +} + +function parseSupportRoutePayload( + schema: T, + input: unknown, +): { success: true; data: z.output } | { success: false; error: string } { + const result = schema.safeParse(normalizeSupportRoutePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatSupportRoutePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseAuthChangePayload(input: unknown): +{ success: true; data: AuthChangePayload } | { success: false; error: string } { + return parseSupportRoutePayload(authChangePayloadSchema, input); +} + +export function parseMonitorConfigPayload(input: unknown): +{ success: true; data: MonitorConfigPayload } | { success: false; error: string } { + return parseSupportRoutePayload(monitorConfigPayloadSchema, input); +} + +export function parseOauthStartPayload(input: unknown): +{ success: true; data: OauthStartPayload } | { success: false; error: string } { + return parseSupportRoutePayload(oauthStartPayloadSchema, input); +} + +export function parseOauthManualCallbackPayload(input: unknown): +{ success: true; data: OauthManualCallbackPayload } | { success: false; error: string } { + return parseSupportRoutePayload(oauthManualCallbackPayloadSchema, input); +} + +export function parseOauthConnectionRebindPayload(input: unknown): +{ success: true; data: OauthConnectionRebindPayload } | { success: false; error: string } { + return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input); +} + +export function parseUpdateCenterConfigPayload(input: unknown): +{ success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } { + return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input); +} + +export function parseUpdateCenterDeployPayload(input: unknown): +{ success: true; data: UpdateCenterDeployPayload } | { success: false; error: string } { + return parseSupportRoutePayload(updateCenterDeployPayloadSchema, input); +} + +export function parseUpdateCenterRollbackPayload(input: unknown): +{ success: true; data: UpdateCenterRollbackPayload } | { success: false; error: string } { + return parseSupportRoutePayload(updateCenterRollbackPayloadSchema, input); +} diff --git a/src/server/contracts/tokenRoutePayloads.ts b/src/server/contracts/tokenRoutePayloads.ts new file mode 100644 index 0000000..c8701f3 --- /dev/null +++ b/src/server/contracts/tokenRoutePayloads.ts @@ -0,0 +1,193 @@ +import { z } from 'zod'; + +const routeChannelCreatePayloadSchema = z.object({ + accountId: z.number().int().positive(), + tokenId: z.union([z.number().int().positive(), z.null()]).optional(), + sourceModel: z.string().optional(), + priority: z.number().optional(), + weight: z.number().optional(), +}).passthrough(); + +const routeChannelBatchCreatePayloadSchema = z.object({ + channels: z.array(z.object({ + accountId: z.number().int().positive(), + tokenId: z.union([z.number().int().positive(), z.null()]).optional(), + sourceModel: z.string().optional(), + }).passthrough()).min(1), +}).passthrough(); + +const routeChannelUpdatePayloadSchema = z.object({ + tokenId: z.union([z.number().int().positive(), z.null()]).optional(), + sourceModel: z.union([z.string(), z.null()]).optional(), + priority: z.number().optional(), + weight: z.number().optional(), + enabled: z.boolean().optional(), +}).passthrough(); + +const tokenRouteCreatePayloadSchema = z.object({ + routeMode: z.string().optional(), + modelPattern: z.string().optional(), + displayName: z.union([z.string(), z.null()]).optional(), + displayIcon: z.union([z.string(), z.null()]).optional(), + modelMapping: z.union([z.string(), z.null()]).optional(), + routingStrategy: z.string().optional(), + enabled: z.boolean().optional(), + sourceRouteIds: z.array(z.number().int().positive()).optional(), +}).passthrough(); + +const tokenRouteUpdatePayloadSchema = z.object({ + routeMode: z.string().optional(), + modelPattern: z.string().optional(), + displayName: z.union([z.string(), z.null()]).optional(), + displayIcon: z.union([z.string(), z.null()]).optional(), + modelMapping: z.union([z.string(), z.null()]).optional(), + routingStrategy: z.string().optional(), + enabled: z.boolean().optional(), + sourceRouteIds: z.array(z.number().int().positive()).optional(), +}).passthrough(); + +const tokenRouteBatchPayloadSchema = z.object({ + ids: z.array(z.number().int().positive()).optional(), + action: z.string().optional(), +}).passthrough(); + +const routeRebuildPayloadSchema = z.object({ + refreshModels: z.boolean().optional(), + wait: z.boolean().optional(), +}).passthrough(); + +export type RouteChannelBatchCreatePayload = z.output; +export type RouteChannelCreatePayload = z.output; +export type RouteChannelUpdatePayload = z.output; +export type RouteRebuildPayload = z.output; +export type TokenRouteBatchPayload = z.output; +export type TokenRouteCreatePayload = z.output; +export type TokenRouteUpdatePayload = z.output; + +function normalizeTokenRoutePayloadInput(input: unknown): unknown { + return input === undefined ? {} : input; +} + +function formatTokenRoutePayloadError(error: z.ZodError): string { + const firstIssue = error.issues[0]; + const [firstPath, secondPath, thirdPath] = firstIssue?.path ?? []; + if (!firstPath) { + return '请求体必须是对象'; + } + if (firstPath === 'routeMode') { + return 'Invalid routeMode. Expected string.'; + } + if (firstPath === 'modelPattern') { + return 'Invalid modelPattern. Expected string.'; + } + if (firstPath === 'displayName') { + return 'Invalid displayName. Expected string or null.'; + } + if (firstPath === 'displayIcon') { + return 'Invalid displayIcon. Expected string or null.'; + } + if (firstPath === 'modelMapping') { + return 'Invalid modelMapping. Expected string or null.'; + } + if (firstPath === 'routingStrategy') { + return 'Invalid routingStrategy. Expected string.'; + } + if (firstPath === 'enabled') { + return 'Invalid enabled. Expected boolean.'; + } + if (firstPath === 'sourceRouteIds') { + return 'Invalid sourceRouteIds. Expected number[].'; + } + if (firstPath === 'ids') { + return 'Invalid ids. Expected number[].'; + } + if (firstPath === 'action') { + return 'Invalid action. Expected string.'; + } + if (firstPath === 'accountId') { + return 'Invalid accountId. Expected positive number.'; + } + if (firstPath === 'tokenId') { + return 'Invalid tokenId. Expected positive number or null.'; + } + if (firstPath === 'sourceModel') { + return 'Invalid sourceModel. Expected string or null.'; + } + if (firstPath === 'priority') { + return 'Invalid priority. Expected number.'; + } + if (firstPath === 'weight') { + return 'Invalid weight. Expected number.'; + } + if (firstPath === 'refreshModels') { + return 'Invalid refreshModels. Expected boolean.'; + } + if (firstPath === 'wait') { + return 'Invalid wait. Expected boolean.'; + } + if (firstPath === 'channels' && thirdPath === 'accountId') { + return 'Invalid channels[].accountId. Expected positive number.'; + } + if (firstPath === 'channels' && thirdPath === 'tokenId') { + return 'Invalid channels[].tokenId. Expected positive number or null.'; + } + if (firstPath === 'channels' && thirdPath === 'sourceModel') { + return 'Invalid channels[].sourceModel. Expected string.'; + } + if (firstPath === 'channels') { + return 'Invalid channels. Expected channel array.'; + } + return 'Invalid token route payload.'; +} + +function parseTokenRoutePayload( + schema: T, + input: unknown, +): { success: true; data: z.output } | { success: false; error: string } { + const result = schema.safeParse(normalizeTokenRoutePayloadInput(input)); + if (!result.success) { + return { + success: false, + error: formatTokenRoutePayloadError(result.error), + }; + } + return { + success: true, + data: result.data, + }; +} + +export function parseTokenRouteCreatePayload(input: unknown): +{ success: true; data: TokenRouteCreatePayload } | { success: false; error: string } { + return parseTokenRoutePayload(tokenRouteCreatePayloadSchema, input); +} + +export function parseTokenRouteUpdatePayload(input: unknown): +{ success: true; data: TokenRouteUpdatePayload } | { success: false; error: string } { + return parseTokenRoutePayload(tokenRouteUpdatePayloadSchema, input); +} + +export function parseTokenRouteBatchPayload(input: unknown): +{ success: true; data: TokenRouteBatchPayload } | { success: false; error: string } { + return parseTokenRoutePayload(tokenRouteBatchPayloadSchema, input); +} + +export function parseRouteChannelCreatePayload(input: unknown): +{ success: true; data: RouteChannelCreatePayload } | { success: false; error: string } { + return parseTokenRoutePayload(routeChannelCreatePayloadSchema, input); +} + +export function parseRouteChannelBatchCreatePayload(input: unknown): +{ success: true; data: RouteChannelBatchCreatePayload } | { success: false; error: string } { + return parseTokenRoutePayload(routeChannelBatchCreatePayloadSchema, input); +} + +export function parseRouteChannelUpdatePayload(input: unknown): +{ success: true; data: RouteChannelUpdatePayload } | { success: false; error: string } { + return parseTokenRoutePayload(routeChannelUpdatePayloadSchema, input); +} + +export function parseRouteRebuildPayload(input: unknown): +{ success: true; data: RouteRebuildPayload } | { success: false; error: string } { + return parseTokenRoutePayload(routeRebuildPayloadSchema, input); +} diff --git a/src/server/routes/api/accountTokens.batch.test.ts b/src/server/routes/api/accountTokens.batch.test.ts index e38fe2e..7e1f474 100644 --- a/src/server/routes/api/accountTokens.batch.test.ts +++ b/src/server/routes/api/accountTokens.batch.test.ts @@ -172,4 +172,18 @@ describe('account token batch routes', () => { expect(response.statusCode).toBe(400); expect((response.json() as { message?: string }).message).toContain('action'); }); + + it('rejects batch payloads whose ids include non-number values', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens/batch', + payload: { + ids: [1, '2'], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('ids'); + }); }); diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index 5b96963..5c951d3 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -572,6 +572,46 @@ describe('account tokens sync routes with site status', () => { expect(syncedDefaultToken?.token).toBe('sk-synced-token'); }); + it('rejects non-boolean wait when syncing all account tokens', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens/sync-all', + payload: { + wait: 'true', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid wait. Expected boolean.', + }); + }); + + it('returns the refreshed default state after creating the first manual token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-default', + token: 'sk-manual-default-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: expect.objectContaining({ + name: 'manual-default', + isDefault: true, + enabled: true, + }), + }); + }); + it('creates token via upstream api and syncs into local store when manual token is omitted', async () => { const { account, site } = await seedAccount({ siteStatus: 'active' }); createApiTokenMock.mockResolvedValue(true); @@ -643,6 +683,40 @@ describe('account tokens sync routes with site status', () => { }); }); + it('rejects non-string token payload when creating manual account token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + token: { value: 'bad-token' }, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('token'); + }); + + it('rejects non-boolean unlimitedQuota payload when creating upstream account token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'typed-token', + unlimitedQuota: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('unlimitedQuota'); + expect(createApiTokenMock).not.toHaveBeenCalled(); + }); + it('returns 400 when limited token misses remainQuota', async () => { const { account } = await seedAccount({ siteStatus: 'active' }); @@ -890,6 +964,66 @@ describe('account tokens sync routes with site status', () => { expect((latest as any)?.valueStatus).toBe('ready'); }); + it('returns the refreshed default state after promoting an existing token to default', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default-token', + token: 'sk-default-token', + source: 'manual', + enabled: true, + isDefault: true, + }).run(); + const secondary = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'secondary-token', + token: 'sk-secondary-token', + source: 'manual', + enabled: true, + isDefault: false, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/account-tokens/${secondary.id}`, + payload: { + isDefault: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: expect.objectContaining({ + id: secondary.id, + isDefault: true, + }), + }); + }); + + it('rejects non-string name payload when updating account token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'typed-token', + token: 'sk-real-token', + source: 'manual', + enabled: true, + isDefault: false, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/account-tokens/${token.id}`, + payload: { + name: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('name'); + }); + it('deletes masked_pending placeholders locally without calling upstream delete', async () => { const { account } = await seedAccount({ siteStatus: 'active' }); const token = await db.insert(schema.accountTokens).values({ diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index ebffde4..647e360 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -24,6 +24,12 @@ import { convergeAccountMutation, refreshAccountCoverageBatch, } from '../../services/accountMutationWorkflow.js'; +import { + parseAccountTokenBatchPayload, + parseAccountTokenCreatePayload, + parseAccountTokenSyncAllPayload, + parseAccountTokenUpdatePayload, +} from '../../contracts/accountTokensRoutePayloads.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -469,22 +475,13 @@ export async function accountTokensRoutes(app: FastifyInstance) { return listTokensWithRelations(Number.isFinite(accountId as number) ? accountId : undefined); }); - app.post<{ Body: { - accountId: number; - name?: string; - token?: string; - enabled?: boolean; - isDefault?: boolean; - source?: string; - group?: string; - unlimitedQuota?: boolean | string; - remainQuota?: number | string; - expiredTime?: number | string; - allowIps?: string; - modelLimitsEnabled?: boolean | string; - modelLimits?: string; - } }>('/api/account-tokens', async (request, reply) => { - const body = request.body; + app.post<{ Body: unknown }>('/api/account-tokens', async (request, reply) => { + const parsedBody = parseAccountTokenCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; const row = await db.select() .from(schema.accounts) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -530,7 +527,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (createdId <= 0) { return reply.code(500).send({ success: false, message: '创建令牌失败' }); } - const created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get(); + let created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get(); if (!created) { return reply.code(500).send({ success: false, message: '创建令牌失败' }); } @@ -541,6 +538,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { await setDefaultToken(created.id); } const coverageRefresh = await refreshCoverageForAccounts([body.accountId]); + created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, created.id)).get(); + if (!created) { + return reply.code(500).send({ success: false, message: '创建令牌失败' }); + } return { success: true, token: created, coverageRefresh }; } @@ -690,9 +691,14 @@ export async function accountTokensRoutes(app: FastifyInstance) { return { success: true }; }; - app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/account-tokens/batch', async (request, reply) => { - const ids = normalizeBatchIds(request.body?.ids); - const action = String(request.body?.action || '').trim(); + app.post<{ Body: unknown }>('/api/account-tokens/batch', async (request, reply) => { + const parsedBody = parseAccountTokenBatchPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + + const ids = normalizeBatchIds(parsedBody.data.ids); + const action = String(parsedBody.data.action || '').trim(); if (ids.length === 0) { return reply.code(400).send({ message: 'ids is required' }); } @@ -757,7 +763,12 @@ export async function accountTokensRoutes(app: FastifyInstance) { }; }); - app.put<{ Params: { id: string }; Body: { name?: string; token?: string; group?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/account-tokens/:id', async (request, reply) => { + const parsedBody = parseAccountTokenUpdatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const tokenId = Number.parseInt(request.params.id, 10); if (Number.isNaN(tokenId)) { return reply.code(400).send({ success: false, message: '令牌 ID 无效' }); @@ -776,7 +787,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } - const body = request.body; + const body = parsedBody.data; const updates: Record = { updatedAt: new Date().toISOString() }; let nextValueStatus = resolveAccountTokenValueStatus(existing); @@ -811,19 +822,24 @@ export async function accountTokensRoutes(app: FastifyInstance) { await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run(); - const latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); + let latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); if (!latest) { return reply.code(500).send({ success: false, message: '更新失败' }); } if (body.isDefault === true && isUsableAccountToken(latest)) { - setDefaultToken(tokenId); + await setDefaultToken(tokenId); } else if (latest.isDefault && isUsableAccountToken(latest)) { - setDefaultToken(tokenId); + await setDefaultToken(tokenId); } else if (existing.isDefault && !isUsableAccountToken(latest)) { - repairDefaultToken(existing.accountId); + await repairDefaultToken(existing.accountId); } else if (body.isDefault === false && existing.isDefault) { - repairDefaultToken(existing.accountId); + await repairDefaultToken(existing.accountId); + } + + latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); + if (!latest) { + return reply.code(500).send({ success: false, message: '更新失败' }); } return { success: true, token: latest }; @@ -982,8 +998,13 @@ export async function accountTokensRoutes(app: FastifyInstance) { return { success: true, ...result, coverageRefresh }; }); - app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => { - if (request.body?.wait) { + app.post<{ Body: unknown }>('/api/account-tokens/sync-all', async (request, reply) => { + const parsedBody = parseAccountTokenSyncAllPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + if (parsedBody.data.wait) { const syncResult = await executeSyncAllAccountTokens(); return { success: true, ...syncResult }; } diff --git a/src/server/routes/api/accounts.batch.test.ts b/src/server/routes/api/accounts.batch.test.ts index 6c8e881..3c90a06 100644 --- a/src/server/routes/api/accounts.batch.test.ts +++ b/src/server/routes/api/accounts.batch.test.ts @@ -108,4 +108,18 @@ describe('accounts batch routes', () => { expect(response.statusCode).toBe(400); expect((response.json() as { message?: string }).message).toContain('action'); }); + + it('rejects batch payloads whose ids include non-number values', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/batch', + payload: { + ids: [1, '2'], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('ids'); + }); }); diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 40d531e..61d6503 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -129,6 +129,54 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { expect(parsedExtra.credentialMode).toBe('apikey'); }); + it('rejects malformed verify-token payloads at the route boundary', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + payload: { + siteId: '1', + accessToken: 'sk-fast-verify', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid siteId. Expected positive number.', + }); + }); + + it('rejects array payloads when adding account', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: [], + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('account payload'); + }); + + it('rejects non-string accessToken when adding account', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Typed Site', + url: 'https://typed.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('accessToken'); + }); + it('marks apikey connection healthy in account list after model discovery succeeds', async () => { const site = await db.insert(schema.sites).values({ name: 'Healthy API Key Site', @@ -349,4 +397,52 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { expect(updated?.isPinned).toBe(true); expect(updated?.sortOrder).toBe(5); }); + + it('rejects array payloads when updating account', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Update Site', + url: 'https://update.example.com', + platform: 'new-api', + }).returning().get(); + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'update-user', + accessToken: 'access-token', + status: 'active', + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: [], + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('account payload'); + }); + + it('rejects non-string username when updating account', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Update Site', + url: 'https://update.example.com', + platform: 'new-api', + }).returning().get(); + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'update-user', + accessToken: 'access-token', + status: 'active', + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: { + username: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('username'); + }); }); diff --git a/src/server/routes/api/accounts.healthRefreshRuntimeState.test.ts b/src/server/routes/api/accounts.healthRefreshRuntimeState.test.ts index d5f844a..ac929ec 100644 --- a/src/server/routes/api/accounts.healthRefreshRuntimeState.test.ts +++ b/src/server/routes/api/accounts.healthRefreshRuntimeState.test.ts @@ -109,6 +109,28 @@ describe('accounts health refresh runtime state', () => { }); }); + it('rejects non-boolean wait payload when refreshing health', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/health/refresh', + payload: { wait: 'true' }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('wait'); + }); + + it('rejects non-number accountId payload when refreshing health', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/health/refresh', + payload: { accountId: '1', wait: true }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('账号 ID'); + }); + it('returns readable task messages when starting a background refresh for all accounts', async () => { const site = await db.insert(schema.sites).values({ name: 'Wind Hub', diff --git a/src/server/routes/api/accounts.login-shield.test.ts b/src/server/routes/api/accounts.login-shield.test.ts index 3633efe..3645665 100644 --- a/src/server/routes/api/accounts.login-shield.test.ts +++ b/src/server/routes/api/accounts.login-shield.test.ts @@ -128,4 +128,22 @@ describe('accounts login shield detection', () => { message: '请求过于频繁,请稍后再试', }); }); + + it('rejects malformed login payloads at the route boundary', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/login', + payload: { + siteId: '1', + username: 'demo-user', + password: 'demo-password', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid siteId. Expected positive number.', + }); + }); }); diff --git a/src/server/routes/api/accounts.manual-models.test.ts b/src/server/routes/api/accounts.manual-models.test.ts index 5b253b9..09ec0a4 100644 --- a/src/server/routes/api/accounts.manual-models.test.ts +++ b/src/server/routes/api/accounts.manual-models.test.ts @@ -153,4 +153,30 @@ describe('accounts manual models endpoint', () => { expect(response.statusCode).toBe(400); }); + + it('rejects non-string manual model entries at the route boundary', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: ['gpt-4-manual', 123], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'Invalid models. Expected string[].', + }); + }); }); diff --git a/src/server/routes/api/accounts.rebind-session.test.ts b/src/server/routes/api/accounts.rebind-session.test.ts index f754eef..d5c91db 100644 --- a/src/server/routes/api/accounts.rebind-session.test.ts +++ b/src/server/routes/api/accounts.rebind-session.test.ts @@ -88,6 +88,33 @@ describe('accounts rebind-session api', { timeout: 15_000 }, () => { expect(latest?.status).toBe('expired'); }); + it('rejects non-string accessToken payload before session rebind verification', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Rebind Site', + url: 'https://rebind.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'linuxdo_1001', + accessToken: 'old-access-token', + status: 'expired', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/rebind-session`, + payload: { + accessToken: { value: 'bad-token' }, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('accessToken'); + expect(verifyTokenMock).not.toHaveBeenCalled(); + }); + it('returns rebind hint when verify reports invalid access token', async () => { verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效')); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 9286cdf..8b0723a 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -31,6 +31,16 @@ import { import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; +import { + parseAccountBatchPayload, + parseAccountCreatePayload, + parseAccountHealthRefreshPayload, + parseAccountLoginPayload, + parseAccountManualModelsPayload, + parseAccountRebindSessionPayload, + parseAccountUpdatePayload, + parseAccountVerifyTokenPayload, +} from '../../contracts/accountsRoutePayloads.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -520,11 +530,16 @@ export async function accountsRoutes(app: FastifyInstance) { }); // Login to a site and auto-create account - app.post<{ Body: { siteId: number; username: string; password: string } }>( + app.post<{ Body: unknown }>( '/api/accounts/login', { preHandler: [limitAccountLogin] }, - async (request) => { - const { siteId, username, password } = request.body; + async (request, reply) => { + const parsedBody = parseAccountLoginPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const { siteId, username, password } = parsedBody.data; // Get site info const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get(); @@ -629,13 +644,18 @@ export async function accountsRoutes(app: FastifyInstance) { ); // Verify credentials against a site. - app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>( + app.post<{ Body: unknown }>( '/api/accounts/verify-token', { preHandler: [limitAccountVerifyToken] }, - async (request) => { - const { siteId, platformUserId } = request.body; - const accessToken = (request.body.accessToken || '').trim(); - const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode); + async (request, reply) => { + const parsedBody = parseAccountVerifyTokenPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const { siteId, platformUserId } = parsedBody.data; + const accessToken = (parsedBody.data.accessToken || '').trim(); + const credentialMode = resolveRequestedCredentialMode(parsedBody.data.credentialMode); const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get(); if (!site) return { success: false, message: 'site not found' }; @@ -934,15 +954,20 @@ export async function accountsRoutes(app: FastifyInstance) { }, ); - app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>( + app.post<{ Params: { id: string }; Body: unknown }>( '/api/accounts/:id/rebind-session', async (request, reply) => { + const parsedBody = parseAccountRebindSessionPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const accountId = Number.parseInt(request.params.id, 10); if (!Number.isFinite(accountId) || accountId <= 0) { return reply.code(400).send({ success: false, message: '账号 ID 无效' }); } - const nextAccessToken = (request.body?.accessToken || '').trim(); + const nextAccessToken = (parsedBody.data.accessToken || '').trim(); if (!nextAccessToken) { return reply.code(400).send({ success: false, message: '请提供新的 Session Token' }); } @@ -963,7 +988,7 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` }); } - const bodyPlatformUserId = Number.parseInt(String(request.body?.platformUserId ?? ''), 10); + const bodyPlatformUserId = Number.parseInt(String(parsedBody.data.platformUserId ?? ''), 10); const candidatePlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0 ? bodyPlatformUserId : resolvePlatformUserId(account.extraConfig, account.username); @@ -1017,8 +1042,8 @@ export async function accountsRoutes(app: FastifyInstance) { } if ((site.platform || '').toLowerCase() === 'sub2api') { const existingManagedAuth = getSub2ApiAuthFromExtraConfig(account.extraConfig); - const requestedRefreshToken = normalizeManagedRefreshToken(request.body?.refreshToken); - const requestedTokenExpiresAt = normalizeManagedTokenExpiresAt(request.body?.tokenExpiresAt); + const requestedRefreshToken = normalizeManagedRefreshToken(parsedBody.data.refreshToken); + const requestedTokenExpiresAt = normalizeManagedTokenExpiresAt(parsedBody.data.tokenExpiresAt); const nextRefreshToken = requestedRefreshToken || existingManagedAuth?.refreshToken; const nextTokenExpiresAt = requestedTokenExpiresAt ?? existingManagedAuth?.tokenExpiresAt; if (nextRefreshToken) { @@ -1054,8 +1079,13 @@ export async function accountsRoutes(app: FastifyInstance) { ); // Add an account (manual credential input) - app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string; skipModelFetch?: boolean } }>('/api/accounts', async (request, reply) => { - const body = request.body; + app.post<{ Body: unknown }>('/api/accounts', async (request, reply) => { + const parsedBody = parseAccountCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get(); if (!site) { return reply.code(400).send({ success: false, message: 'site not found' }); @@ -1234,9 +1264,13 @@ export async function accountsRoutes(app: FastifyInstance) { }); // Update an account - app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/accounts/:id', async (request, reply) => { const id = parseInt(request.params.id); - const body = request.body as Record; + const parsedBody = parseAccountUpdatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + const body = parsedBody.data as Record; const row = await db.select() .from(schema.accounts) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -1342,9 +1376,14 @@ export async function accountsRoutes(app: FastifyInstance) { return { success: true }; }); - app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/accounts/batch', async (request, reply) => { - const ids = normalizeBatchIds(request.body?.ids); - const action = String(request.body?.action || '').trim(); + app.post<{ Body: unknown }>('/api/accounts/batch', async (request, reply) => { + const parsedBody = parseAccountBatchPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + + const ids = normalizeBatchIds(parsedBody.data.ids); + const action = String(parsedBody.data.action || '').trim(); if (ids.length === 0) { return reply.code(400).send({ message: 'ids is required' }); } @@ -1402,16 +1441,15 @@ export async function accountsRoutes(app: FastifyInstance) { }; }); - app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => { - const rawAccountId = request.body?.accountId as unknown; - const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== ''; - const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined; - const wait = request.body?.wait === true; - - if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) { - return reply.code(400).send({ success: false, message: '账号 ID 无效' }); + app.post<{ Body: unknown }>('/api/accounts/health/refresh', async (request, reply) => { + const parsedBody = parseAccountHealthRefreshPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); } + const accountId = parsedBody.data.accountId; + const wait = parsedBody.data.wait === true; + if (wait) { const result = await executeRefreshAccountRuntimeHealth(accountId); if (accountId && result.summary.total === 0) { @@ -1527,13 +1565,18 @@ export async function accountsRoutes(app: FastifyInstance) { }); // Add models manually to an account - app.post<{ Params: { id: string }; Body: { models: string[] } }>('/api/accounts/:id/models/manual', async (request, reply) => { + app.post<{ Params: { id: string }; Body: unknown }>('/api/accounts/:id/models/manual', async (request, reply) => { + const parsedBody = parseAccountManualModelsPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + const accountId = parseInt(request.params.id, 10); if (!Number.isFinite(accountId) || accountId <= 0) { return reply.code(400).send({ message: '账号 ID 无效' }); } - const { models } = request.body; + const { models } = parsedBody.data; if (!Array.isArray(models) || models.length === 0) { return reply.code(400).send({ message: '模型列表不能为空' }); } @@ -1608,4 +1651,3 @@ export async function accountsRoutes(app: FastifyInstance) { } }); } - diff --git a/src/server/routes/api/auth.test.ts b/src/server/routes/api/auth.test.ts new file mode 100644 index 0000000..9de4b64 --- /dev/null +++ b/src/server/routes/api/auth.test.ts @@ -0,0 +1,66 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +type ConfigModule = typeof import('../../config.js'); +type DbModule = typeof import('../../db/index.js'); + +describe('auth routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let config: ConfigModule['config']; + let dataDir = ''; + let originalDataDir: string | undefined; + let originalAuthToken = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-auth-routes-')); + originalDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const configModule = await import('../../config.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./auth.js'); + db = dbModule.db; + schema = dbModule.schema; + config = configModule.config; + originalAuthToken = config.authToken; + + app = Fastify(); + await app.register(routesModule.authRoutes); + }); + + beforeEach(async () => { + config.authToken = 'secret-token'; + await db.delete(schema.events).run(); + await db.delete(schema.settings).run(); + }); + + afterAll(async () => { + config.authToken = originalAuthToken; + await app.close(); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + }); + + it('rejects malformed auth change payloads at the route boundary', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/auth/change', + payload: { + oldToken: 'secret-token', + newToken: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid newToken. Expected string.', + }); + }); +}); diff --git a/src/server/routes/api/auth.ts b/src/server/routes/api/auth.ts index a5528cd..7244f88 100644 --- a/src/server/routes/api/auth.ts +++ b/src/server/routes/api/auth.ts @@ -4,6 +4,7 @@ import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; +import { parseAuthChangePayload } from '../../contracts/supportRoutePayloads.js'; const limitAdminTokenChange = createRateLimitGuard({ bucket: 'auth-change', @@ -13,11 +14,16 @@ const limitAdminTokenChange = createRateLimitGuard({ export async function authRoutes(app: FastifyInstance) { // Change admin auth token (requires old token verification) - app.post<{ Body: { oldToken: string; newToken: string } }>( + app.post<{ Body: unknown }>( '/api/settings/auth/change', { preHandler: [limitAdminTokenChange] }, async (request, reply) => { - const { oldToken, newToken } = request.body; + const parsedBody = parseAuthChangePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const { oldToken, newToken } = parsedBody.data; if (!oldToken || !newToken) { return reply.code(400).send({ success: false, message: '请填写所有字段' }); diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts index cd5610d..8c1f120 100644 --- a/src/server/routes/api/downstreamApiKeys.test.ts +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -352,6 +352,61 @@ describe('downstream api keys routes', () => { }); }); + it('rejects malformed create, update, and batch payloads at the route boundary', async () => { + const invalidCreateRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'bad-create', + key: 'sk-bad-create-001', + enabled: 'false', + }, + }); + expect(invalidCreateRes.statusCode).toBe(400); + expect(invalidCreateRes.json()).toMatchObject({ + success: false, + message: 'Invalid enabled. Expected boolean.', + }); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'shape-ok', + key: 'sk-shape-ok-001', + }, + }); + expect(createRes.statusCode).toBe(200); + const keyId = createRes.json().item.id as number; + + const invalidUpdateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + tags: ['valid', 123], + }, + }); + expect(invalidUpdateRes.statusCode).toBe(400); + expect(invalidUpdateRes.json()).toMatchObject({ + success: false, + message: 'Invalid tags. Expected string or string[].', + }); + + const invalidBatchRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [String(keyId)], + action: 'disable', + }, + }); + expect(invalidBatchRes.statusCode).toBe(400); + expect(invalidBatchRes.json()).toMatchObject({ + success: false, + message: 'Invalid ids. Expected number[].', + }); + }); + it('returns summary, overview and trend aggregated from proxy logs', async () => { const inserted = await db.insert(schema.downstreamApiKeys).values({ name: 'analytics-key', diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index c48c273..c2d19d0 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -9,6 +9,10 @@ import { toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { + parseDownstreamApiKeyBatchPayload, + parseDownstreamApiKeyPayload, +} from '../../contracts/downstreamApiKeyRoutePayloads.js'; function parseRouteId(raw: string): number | null { const id = Number.parseInt(raw, 10); @@ -439,25 +443,16 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { }; }); - app.post<{ - Body: { - name?: unknown; - key?: unknown; - description?: unknown; - groupName?: unknown; - tags?: unknown; - enabled?: unknown; - expiresAt?: unknown; - maxCost?: unknown; - maxRequests?: unknown; - supportedModels?: unknown; - allowedRouteIds?: unknown; - siteWeightMultipliers?: unknown; - }; - }>('/api/downstream-keys', async (request, reply) => { + app.post<{ Body: unknown }>('/api/downstream-keys', async (request, reply) => { + const parsedBody = parseDownstreamApiKeyPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; let normalized: ReturnType; try { - normalized = normalizeDownstreamApiKeyPayload(request.body || {}); + normalized = normalizeDownstreamApiKeyPayload(body); } catch (error: unknown) { return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' }); } @@ -523,23 +518,12 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { } }); - app.put<{ - Params: { id: string }; - Body: { - name?: unknown; - key?: unknown; - description?: unknown; - groupName?: unknown; - tags?: unknown; - enabled?: unknown; - expiresAt?: unknown; - maxCost?: unknown; - maxRequests?: unknown; - supportedModels?: unknown; - allowedRouteIds?: unknown; - siteWeightMultipliers?: unknown; - }; - }>('/api/downstream-keys/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/downstream-keys/:id', async (request, reply) => { + const parsedBody = parseDownstreamApiKeyPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const id = parseRouteId(request.params.id); if (!id) { return reply.code(400).send({ success: false, message: 'id 无效' }); @@ -554,21 +538,22 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { } const existingView = toDownstreamApiKeyPolicyView(existing); + const body = parsedBody.data; let normalized: ReturnType; try { normalized = normalizeDownstreamApiKeyPayload({ - name: request.body?.name ?? existing.name, - key: request.body?.key ?? existing.key, - description: request.body?.description ?? existing.description, - groupName: request.body?.groupName ?? existing.groupName, - tags: request.body?.tags ?? existingView.tags, - enabled: request.body?.enabled ?? existing.enabled, - expiresAt: request.body?.expiresAt ?? existing.expiresAt, - maxCost: request.body?.maxCost ?? existing.maxCost, - maxRequests: request.body?.maxRequests ?? existing.maxRequests, - supportedModels: request.body?.supportedModels ?? existingView.supportedModels, - allowedRouteIds: request.body?.allowedRouteIds ?? existingView.allowedRouteIds, - siteWeightMultipliers: request.body?.siteWeightMultipliers ?? existingView.siteWeightMultipliers, + name: body.name ?? existing.name, + key: body.key ?? existing.key, + description: body.description ?? existing.description, + groupName: body.groupName ?? existing.groupName, + tags: body.tags ?? existingView.tags, + enabled: body.enabled ?? existing.enabled, + expiresAt: body.expiresAt ?? existing.expiresAt, + maxCost: body.maxCost ?? existing.maxCost, + maxRequests: body.maxRequests ?? existing.maxRequests, + supportedModels: body.supportedModels ?? existingView.supportedModels, + allowedRouteIds: body.allowedRouteIds ?? existingView.allowedRouteIds, + siteWeightMultipliers: body.siteWeightMultipliers ?? existingView.siteWeightMultipliers, }); } catch (error: unknown) { return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' }); @@ -663,16 +648,15 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true }; }); - app.post<{ Body?: { - ids?: number[]; - action?: string; - groupOperation?: string; - groupName?: unknown; - tagOperation?: string; - tags?: unknown; - } }>('/api/downstream-keys/batch', async (request, reply) => { - const ids = normalizeBatchIds(request.body?.ids); - const action = String(request.body?.action || '').trim(); + app.post<{ Body: unknown }>('/api/downstream-keys/batch', async (request, reply) => { + const parsedBody = parseDownstreamApiKeyBatchPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; + const ids = normalizeBatchIds(body.ids); + const action = String(body.action || '').trim(); if (ids.length === 0) { return reply.code(400).send({ success: false, message: 'ids is required' }); } @@ -680,10 +664,10 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'Invalid action' }); } - const groupOperation = String(request.body?.groupOperation || 'keep').trim(); - const tagOperation = String(request.body?.tagOperation || 'keep').trim(); - const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName; - const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags; + const groupOperation = String(body.groupOperation || 'keep').trim(); + const tagOperation = String(body.tagOperation || 'keep').trim(); + const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: body.groupName }).groupName; + const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: body.tags }).tags; if (action === 'updateMetadata') { if (!['keep', 'set', 'clear'].includes(groupOperation)) { diff --git a/src/server/routes/api/monitor.test.ts b/src/server/routes/api/monitor.test.ts new file mode 100644 index 0000000..6843efe --- /dev/null +++ b/src/server/routes/api/monitor.test.ts @@ -0,0 +1,53 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +type DbModule = typeof import('../../db/index.js'); + +describe('monitor routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-monitor-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./monitor.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.monitorRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.settings).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('rejects malformed monitor config payloads at the route boundary', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/monitor/config', + payload: { + ldohCookie: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid ldohCookie. Expected string or null.', + }); + }); +}); diff --git a/src/server/routes/api/monitor.ts b/src/server/routes/api/monitor.ts index 7dd375b..5465f79 100644 --- a/src/server/routes/api/monitor.ts +++ b/src/server/routes/api/monitor.ts @@ -4,6 +4,7 @@ import { upsertSetting } from '../../db/upsertSetting.js'; import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; +import { parseMonitorConfigPayload } from '../../contracts/supportRoutePayloads.js'; const MONITOR_AUTH_COOKIE = 'meta_monitor_auth'; const LDOH_BASE_URL = 'https://ldoh.105117.xyz'; @@ -136,11 +137,16 @@ export async function monitorRoutes(app: FastifyInstance) { }; }); - app.put<{ Body: { ldohCookie?: string | null } }>( + app.put<{ Body: unknown }>( '/api/monitor/config', { preHandler: [limitMonitorConfigWrite] }, async (request, reply) => { - const raw = String(request.body?.ldohCookie || '').trim(); + const parsedBody = parseMonitorConfigPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const raw = String(parsedBody.data.ldohCookie || '').trim(); if (!raw) { await upsertSetting(LDOH_COOKIE_SETTING_KEY, ''); return { success: true, message: 'LDOH Cookie 已清空', ldohCookieConfigured: false }; diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 634bf09..65d06a4 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -121,6 +121,55 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); }); + it('rejects malformed oauth payloads at the route boundary', async () => { + const invalidStartResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + payload: { + accountId: '1', + }, + }); + expect(invalidStartResponse.statusCode).toBe(400); + expect(invalidStartResponse.json()).toMatchObject({ + message: 'Invalid accountId. Expected positive number.', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const state = (startResponse.json() as { state: string }).state; + + const invalidCallbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, + payload: { + callbackUrl: 123, + }, + }); + expect(invalidCallbackResponse.statusCode).toBe(400); + expect(invalidCallbackResponse.json()).toMatchObject({ + message: 'Invalid callbackUrl. Expected string.', + }); + + const invalidRebindResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/connections/1/rebind', + payload: { + proxyUrl: 123, + }, + }); + expect(invalidRebindResponse.statusCode).toBe(400); + expect(invalidRebindResponse.json()).toMatchObject({ + message: 'Invalid proxyUrl. Expected string or null.', + }); + }); + it('starts an antigravity oauth session and returns provider metadata', async () => { const startResponse = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts index 5a819ef..2c6943a 100644 --- a/src/server/routes/api/oauth.ts +++ b/src/server/routes/api/oauth.ts @@ -12,6 +12,11 @@ import { submitOauthManualCallback, } from '../../services/oauth/service.js'; import { parseSiteProxyUrlInput } from '../../services/siteProxy.js'; +import { + parseOauthConnectionRebindPayload, + parseOauthManualCallbackPayload, + parseOauthStartPayload, +} from '../../contracts/supportRoutePayloads.js'; const limitOauthProviderRead = createRateLimitGuard({ bucket: 'oauth-provider-read', @@ -111,21 +116,27 @@ export async function oauthRoutes(app: FastifyInstance) { providers: listOauthProviders(), })); - app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string; proxyUrl?: string | null } }>( + app.post<{ Params: { provider: string }; Body: unknown }>( '/api/oauth/providers/:provider/start', { preHandler: [limitOauthStart] }, async (request, reply) => { - const rebindAccountId = request.body?.accountId === undefined + const parsedBody = parseOauthStartPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + + const body = parsedBody.data; + const rebindAccountId = body.accountId === undefined ? undefined - : parsePositiveInteger(request.body.accountId); - if (request.body?.accountId !== undefined && rebindAccountId === null) { + : parsePositiveInteger(body.accountId); + if (body.accountId !== undefined && rebindAccountId === null) { return reply.code(400).send({ message: 'invalid account id' }); } - const projectId = parseOptionalProjectId(request.body?.projectId); - if (request.body?.projectId !== undefined && projectId === null) { + const projectId = parseOptionalProjectId(body.projectId); + if (body.projectId !== undefined && projectId === null) { return reply.code(400).send({ message: 'invalid project id' }); } - const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl); + const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl); if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) { return reply.code(400).send({ message: 'invalid proxy url' }); } @@ -156,12 +167,17 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); - app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>( + app.post<{ Params: { state: string }; Body: unknown }>( '/api/oauth/sessions/:state/manual-callback', { preHandler: [limitOauthSessionMutate] }, async (request, reply) => { - const callbackUrl = typeof request.body?.callbackUrl === 'string' - ? request.body.callbackUrl.trim() + const parsedBody = parseOauthManualCallbackPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + + const callbackUrl = typeof parsedBody.data.callbackUrl === 'string' + ? parsedBody.data.callbackUrl.trim() : ''; if (!callbackUrl) { return reply.code(400).send({ message: 'invalid oauth callback url' }); @@ -209,15 +225,20 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); - app.post<{ Params: { accountId: string }; Body?: { proxyUrl?: string | null } }>( + app.post<{ Params: { accountId: string }; Body: unknown }>( '/api/oauth/connections/:accountId/rebind', { preHandler: [limitOauthConnectionMutate] }, async (request, reply) => { + const parsedBody = parseOauthConnectionRebindPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + const accountId = parsePositiveInteger(request.params.accountId); if (accountId === null) { return reply.code(400).send({ message: 'invalid account id' }); } - const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl); + const normalizedProxyUrl = parseSiteProxyUrlInput(parsedBody.data.proxyUrl); if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) { return reply.code(400).send({ message: 'invalid proxy url' }); } diff --git a/src/server/routes/api/settings.backup-webdav.test.ts b/src/server/routes/api/settings.backup-webdav.test.ts index 9343731..fb679e2 100644 --- a/src/server/routes/api/settings.backup-webdav.test.ts +++ b/src/server/routes/api/settings.backup-webdav.test.ts @@ -84,31 +84,80 @@ describe('settings backup webdav api', () => { it('exports current backup to webdav through settings route', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); fetchSpy.mockResolvedValue(new Response(null, { status: 201 })); + try { + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 * * * *', + }), + }).run(); - await db.insert(schema.settings).values({ - key: 'backup_webdav_config_v1', - value: JSON.stringify({ + const response = await app.inject({ + method: 'POST', + url: '/api/settings/backup/webdav/export', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { success?: boolean; fileUrl?: string }; + expect(body.success).toBe(true); + expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('rejects invalid exportType payload when saving webdav config', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/backup/webdav', + payload: { enabled: true, fileUrl: 'https://dav.example.com/backups/metapi.json', - username: 'alice', - password: 'secret-pass', - exportType: 'all', - autoSyncEnabled: false, - autoSyncCron: '0 * * * *', - }), - }).run(); - - const response = await app.inject({ - method: 'POST', - url: '/api/settings/backup/webdav/export', + exportType: 123, + }, }); - expect(response.statusCode).toBe(200); - const body = response.json() as { success?: boolean; fileUrl?: string }; - expect(body.success).toBe(true); - expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json'); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('exportType'); + }); - fetchSpy.mockRestore(); + it('rejects invalid export body type instead of silently falling back', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValue(new Response(null, { status: 201 })); + try { + await db.insert(schema.settings).values({ + key: 'backup_webdav_config_v1', + value: JSON.stringify({ + enabled: true, + fileUrl: 'https://dav.example.com/backups/metapi.json', + username: 'alice', + password: 'secret-pass', + exportType: 'all', + autoSyncEnabled: false, + autoSyncCron: '0 * * * *', + }), + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/backup/webdav/export', + payload: { + type: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('type'); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } }); }); diff --git a/src/server/routes/api/settings.databaseMigration.test.ts b/src/server/routes/api/settings.databaseMigration.test.ts index 6c5ce48..01fd1a4 100644 --- a/src/server/routes/api/settings.databaseMigration.test.ts +++ b/src/server/routes/api/settings.databaseMigration.test.ts @@ -138,5 +138,20 @@ describe('settings database migration api', () => { targetDb.close(); } }); -}); + it('rejects non-boolean overwrite payload when migrating through settings api', async () => { + const targetPath = join(dataDir, 'target-migrate-invalid.db'); + const response = await app.inject({ + method: 'POST', + url: '/api/settings/database/migrate', + payload: { + dialect: 'sqlite', + connectionString: targetPath, + overwrite: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('overwrite'); + }); +}); diff --git a/src/server/routes/api/settings.databaseRuntime.test.ts b/src/server/routes/api/settings.databaseRuntime.test.ts index a1cbb8c..4120b6f 100644 --- a/src/server/routes/api/settings.databaseRuntime.test.ts +++ b/src/server/routes/api/settings.databaseRuntime.test.ts @@ -176,6 +176,21 @@ describe('settings database runtime config api', () => { expect(putBody.saved?.ssl).toBe(false); }); + it('rejects non-boolean ssl payload when saving runtime database config', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/database/runtime', + payload: { + dialect: 'postgres', + connectionString: 'postgres://user:pass@db.example.com:5432/mydb', + ssl: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('ssl'); + }); + it('includes ssl in active runtime state', async () => { const response = await app.inject({ method: 'GET', diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 9d9f197..2c16956 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -278,6 +278,21 @@ describe('settings and auth events', () => { expect(body.message).toContain('Webhook URL'); }); + it('rejects non-boolean webhookEnabled payloads instead of coercing them', async () => { + (config as any).webhookEnabled = false; + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + webhookEnabled: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('Webhook 开关'); + expect(config.webhookEnabled).toBe(false); + }); + it('rejects telegram config when bot token is missing but telegram is enabled', async () => { const response = await app.inject({ method: 'PUT', @@ -405,6 +420,20 @@ describe('settings and auth events', () => { expect(runtime.telegramUseSystemProxy).toBe(true); }); + it('rejects non-boolean telegram use system proxy payloads instead of coercing them', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramUseSystemProxy: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('Telegram 使用系统代理'); + expect((config as any).telegramUseSystemProxy).toBe(false); + }); + it('persists and returns routing fallback unit cost from runtime settings', async () => { const updateResponse = await app.inject({ method: 'PUT', diff --git a/src/server/routes/api/settings.system-proxy-test.test.ts b/src/server/routes/api/settings.system-proxy-test.test.ts index 0eaaa8c..82b9f27 100644 --- a/src/server/routes/api/settings.system-proxy-test.test.ts +++ b/src/server/routes/api/settings.system-proxy-test.test.ts @@ -103,6 +103,32 @@ describe('settings system proxy test route', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('rejects array payloads for system proxy test', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: [], + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('settings payload'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects non-string proxyUrl payloads for system proxy test', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 123, + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('string'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('returns 502 when the proxy probe request fails', async () => { fetchMock.mockRejectedValue(new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'), diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index fc364d5..0fa246d 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -26,6 +26,14 @@ import { testDatabaseConnection, type MigrationDialect, } from '../../services/databaseMigrationService.js'; +import { + parseSystemProxyTestPayload, + parseDatabaseMigrationPayload, + parseBackupImportPayload, + parseRuntimeSettingsPayload, + parseBackupWebdavConfigPayload, + parseBackupWebdavExportPayload, +} from '../../contracts/settingsRoutePayloads.js'; import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js'; @@ -798,10 +806,18 @@ export async function settingsRoutes(app: FastifyInstance) { return { brands: getAllBrandNames() }; }); - app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { - const rawProxyUrl = request.body?.proxyUrl === undefined + app.post<{ Body: unknown }>('/api/settings/system-proxy/test', async (request, reply) => { + const parsedBody = parseSystemProxyTestPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const rawProxyUrl = parsedBody.data.proxyUrl === undefined ? config.systemProxyUrl - : String(request.body.proxyUrl || '').trim(); + : String(parsedBody.data.proxyUrl || '').trim(); const normalizedProxyUrl = rawProxyUrl ? normalizeSiteProxyUrl(rawProxyUrl) : ''; @@ -835,8 +851,16 @@ export async function settingsRoutes(app: FastifyInstance) { } }); - app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => { - const body = request.body || {}; + app.put<{ Body: unknown }>('/api/settings/runtime', async (request, reply) => { + const parsedBody = parseRuntimeSettingsPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const body = parsedBody.data as RuntimeSettingsBody; const changedLabels: string[] = []; const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']); @@ -1590,9 +1614,17 @@ export async function settingsRoutes(app: FastifyInstance) { }; }); - app.put<{ Body: DatabaseMigrationBody }>('/api/settings/database/runtime', async (request, reply) => { + app.put<{ Body: unknown }>('/api/settings/database/runtime', async (request, reply) => { try { - const normalized = normalizeMigrationInput(request.body || {}); + const parsedBody = parseDatabaseMigrationPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const normalized = normalizeMigrationInput(parsedBody.data); await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect); await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString); await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl); @@ -1622,9 +1654,17 @@ export async function settingsRoutes(app: FastifyInstance) { } }); - app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => { + app.post<{ Body: unknown }>('/api/settings/database/test-connection', async (request, reply) => { try { - const result = await testDatabaseConnection(request.body || {}); + const parsedBody = parseDatabaseMigrationPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const result = await testDatabaseConnection(parsedBody.data); return { success: true, message: '目标数据库连接成功', @@ -1638,9 +1678,17 @@ export async function settingsRoutes(app: FastifyInstance) { } }); - app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/migrate', async (request, reply) => { + app.post<{ Body: unknown }>('/api/settings/database/migrate', async (request, reply) => { try { - const result = await migrateCurrentDatabase(request.body || {}); + const parsedBody = parseDatabaseMigrationPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const result = await migrateCurrentDatabase(parsedBody.data); appendSettingsEvent({ type: 'status', title: '数据库迁移已完成', @@ -1668,14 +1716,14 @@ export async function settingsRoutes(app: FastifyInstance) { return await exportBackup(type); }); - app.post<{ Body: { data?: Record } }>('/api/settings/backup/import', async (request, reply) => { - const payload = request.body?.data; - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + app.post<{ Body: unknown }>('/api/settings/backup/import', async (request, reply) => { + const parsedBody = parseBackupImportPayload(request.body); + if (!parsedBody.success) { return reply.code(400).send({ success: false, message: '导入数据格式错误:需要 JSON 对象' }); } try { - const result = await importBackup(payload); + const result = await importBackup(parsedBody.data.data); for (const item of result.appliedSettings) { applyImportedSettingToRuntime(item.key, item.value); } @@ -1701,7 +1749,15 @@ export async function settingsRoutes(app: FastifyInstance) { app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => { try { - const body = request.body || {}; + const parsedBody = parseBackupWebdavConfigPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const body = parsedBody.data; const result = await saveBackupWebdavConfig({ enabled: body.enabled === undefined ? undefined : body.enabled === true, fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''), @@ -1723,7 +1779,15 @@ export async function settingsRoutes(app: FastifyInstance) { app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => { try { - const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : ''; + const parsedBody = parseBackupWebdavExportPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const rawType = typeof parsedBody.data.type === 'string' ? parsedBody.data.type.trim().toLowerCase() : ''; const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences' ? rawType : undefined; diff --git a/src/server/routes/api/sites.batch.test.ts b/src/server/routes/api/sites.batch.test.ts index 29423da..acac98f 100644 --- a/src/server/routes/api/sites.batch.test.ts +++ b/src/server/routes/api/sites.batch.test.ts @@ -89,4 +89,20 @@ describe('sites batch routes', () => { expect(response.statusCode).toBe(400); expect((response.json() as { message?: string }).message).toContain('action'); }); + + it('rejects non-number site ids at the route boundary', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites/batch', + payload: { + ids: ['1'], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + message: 'Invalid ids. Expected number[].', + }); + }); }); diff --git a/src/server/routes/api/sites.disabledModels.test.ts b/src/server/routes/api/sites.disabledModels.test.ts index 29ee482..0a428c9 100644 --- a/src/server/routes/api/sites.disabledModels.test.ts +++ b/src/server/routes/api/sites.disabledModels.test.ts @@ -178,6 +178,22 @@ describe('sites disabled models API', () => { expect(resp.statusCode).toBe(400); }); + it('returns 400 when models contains non-string entries', async () => { + const site = await db.insert(schema.sites).values({ + name: 'test-site', + url: 'https://test-site.example.com', + platform: 'new-api', + }).returning().get(); + + const resp = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}/disabled-models`, + payload: { models: ['gpt-4o', 123] }, + }); + expect(resp.statusCode).toBe(400); + expect(resp.json()).toMatchObject({ error: 'Invalid models. Expected string[].' }); + }); + it('deduplicates model names', async () => { const site = await db.insert(schema.sites).values({ name: 'test-site', diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index cfd74c9..45058da 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -96,6 +96,32 @@ describe('sites proxy settings', () => { expect((duplicate.json() as { error?: string }).error).toContain('already exists'); }); + it('normalizes platform before conflict checks when creating a site', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'existing-site', + url: 'https://duplicate-site.example.com/', + platform: 'new-api', + }, + }); + expect(first.statusCode).toBe(200); + + const duplicate = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'duplicate-site', + url: 'https://duplicate-site.example.com', + platform: ' new-api ', + }, + }); + + expect(duplicate.statusCode).toBe(409); + expect((duplicate.json() as { error?: string }).error).toContain('already exists'); + }); + it('rejects invalid useSystemProxy flag', async () => { const response = await app.inject({ method: 'POST', @@ -226,6 +252,31 @@ describe('sites proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('already exists'); }); + it('rejects blank platform updates instead of persisting an empty platform', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'editable-site', + url: 'https://editable-site.example.com', + platform: 'new-api', + }, + }); + expect(created.statusCode).toBe(200); + const site = created.json() as { id: number }; + + const response = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}`, + payload: { + platform: ' ', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('platform'); + }); + it('rejects invalid custom headers json', async () => { const response = await app.inject({ method: 'POST', @@ -304,7 +355,7 @@ describe('sites proxy settings', () => { const response = await app.inject({ method: 'POST', url: '/api/sites', - payload: null, + payload: [], }); expect(response.statusCode).toBe(400); @@ -327,7 +378,7 @@ describe('sites proxy settings', () => { const response = await app.inject({ method: 'PUT', url: `/api/sites/${site.id}`, - payload: null, + payload: [], }); expect(response.statusCode).toBe(400); @@ -368,6 +419,19 @@ describe('sites proxy settings', () => { }); }); + it('rejects empty detect payload urls at the route boundary', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites/detect', + payload: { + url: ' ', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('url'); + }); + it('does not force CodingPlan preset metadata when the user explicitly chooses generic openai', async () => { const response = await app.inject({ method: 'POST', @@ -407,66 +471,6 @@ describe('sites proxy settings', () => { }); }); - it('canonicalizes create payload url and platform before persistence and conflict checks', async () => { - const created = await app.inject({ - method: 'POST', - url: '/api/sites', - payload: { - name: 'Aliyun Generic OpenAI', - url: ' coding.dashscope.aliyuncs.com/v1/ ', - platform: ' OPENAI ', - }, - }); - - expect(created.statusCode).toBe(200); - expect(created.json()).toMatchObject({ - name: 'Aliyun Generic OpenAI', - url: 'https://coding.dashscope.aliyuncs.com/v1', - platform: 'openai', - }); - - const duplicate = await app.inject({ - method: 'POST', - url: '/api/sites', - payload: { - name: 'Aliyun Generic OpenAI Duplicate', - url: 'https://coding.dashscope.aliyuncs.com/v1', - platform: 'openai', - }, - }); - - expect(duplicate.statusCode).toBe(409); - expect((duplicate.json() as { error?: string }).error).toContain('already exists'); - }); - - it('canonicalizes update payload url and platform before saving', async () => { - const created = await app.inject({ - method: 'POST', - url: '/api/sites', - payload: { - name: 'Update Canonicalization', - url: 'https://update-canonicalization.example.com', - platform: 'new-api', - }, - }); - expect(created.statusCode).toBe(200); - - const response = await app.inject({ - method: 'PUT', - url: `/api/sites/${(created.json() as { id: number }).id}`, - payload: { - url: ' coding.dashscope.aliyuncs.com/v1/ ', - platform: ' OPENAI ', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - url: 'https://coding.dashscope.aliyuncs.com/v1', - platform: 'openai', - }); - }); - it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 5c050fb..9ab8745 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -2,13 +2,19 @@ import { FastifyInstance, FastifyReply } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, eq } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; -import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { + parseSiteBatchPayload, + parseSiteCreatePayload, + parseSiteDetectPayload, + parseSiteDisabledModelsPayload, + parseSiteUpdatePayload, +} from '../../contracts/siteRoutePayloads.js'; import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js'; -import { stripTrailingSlashes } from '../../services/urlNormalization.js'; function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { if (input === undefined || input === null) return null; @@ -84,15 +90,10 @@ type ErrorLike = { cause?: unknown; }; -function normalizeSiteUrl(url: string): string { - const trimmed = url.trim(); - const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`; - return stripTrailingSlashes(withScheme); -} - -function normalizeSitePlatform(platform?: string): string | undefined { - if (platform === undefined) return undefined; - return platform.trim().toLowerCase(); +function normalizeOptionalPlatform(value: string | undefined): string | null { + if (value === undefined) return null; + const normalized = value.trim(); + return normalized || null; } function getErrorChain(error: unknown): ErrorLike[] { @@ -285,23 +286,12 @@ export async function sitesRoutes(app: FastifyInstance) { }); // Add a site - app.post<{ Body: { - name: string; - url: string; - platform?: string; - initializationPresetId?: string | null; - proxyUrl?: string | null; - useSystemProxy?: boolean; - customHeaders?: string | null; - externalCheckinUrl?: string | null; - status?: string; - isPinned?: boolean; - sortOrder?: number; - globalWeight?: number; - } }>('/api/sites', async (request, reply) => { - if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) { - return reply.code(400).send({ error: 'Invalid site payload. Expected object.' }); + app.post<{ Body: unknown }>('/api/sites', async (request, reply) => { + const parsedBody = parseSiteCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ error: parsedBody.error }); } + const { name, url, @@ -315,16 +305,7 @@ export async function sitesRoutes(app: FastifyInstance) { isPinned, sortOrder, globalWeight, - } = request.body; - if (typeof name !== 'string' || !name.trim()) { - return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' }); - } - if (typeof url !== 'string' || !url.trim()) { - return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' }); - } - if (platform !== undefined && typeof platform !== 'string') { - return reply.code(400).send({ error: 'Invalid platform. Expected string.' }); - } + } = parsedBody.data; const normalizedStatus = normalizeSiteStatus(status); if (status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -366,17 +347,16 @@ export async function sitesRoutes(app: FastifyInstance) { const existingSites = await db.select().from(schema.sites).all(); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); - const canonicalUrl = normalizeSiteUrl(url); - const canonicalPlatform = normalizeSitePlatform(platform); + const normalizedUrl = normalizeSiteUrl(url); - let detectedPlatform = canonicalPlatform; + let detectedPlatform = normalizeOptionalPlatform(platform); let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null; if (!detectedPlatform) { if (explicitInitializationPreset) { detectedPlatform = explicitInitializationPreset.platform; } else { - const detected = await detectSite(canonicalUrl); - detectedPlatform = detected?.platform; + const detected = await detectSite(url); + detectedPlatform = detected?.platform ?? null; responseInitializationPresetId = detected?.initializationPresetId || null; } } @@ -386,16 +366,16 @@ export async function sitesRoutes(app: FastifyInstance) { if (!detectedPlatform) { return { error: 'Could not detect platform. Please specify manually.' }; } - const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl); + const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl); if (conflictingSite) { - return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl); + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); } let inserted; try { inserted = await db.insert(schema.sites).values({ name, - url: canonicalUrl, + url: normalizedUrl, platform: detectedPlatform, proxyUrl: normalizedProxyUrl.proxyUrl, useSystemProxy: normalizedUseSystemProxy ?? false, @@ -408,7 +388,7 @@ export async function sitesRoutes(app: FastifyInstance) { }).run(); } catch (error) { if (isSitesPlatformUrlConflict(error)) { - return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl); + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); } throw error; } @@ -428,19 +408,7 @@ export async function sitesRoutes(app: FastifyInstance) { }); // Update a site - app.put<{ Params: { id: string }; Body: { - name?: string; - url?: string; - platform?: string; - proxyUrl?: string | null; - useSystemProxy?: boolean; - customHeaders?: string | null; - externalCheckinUrl?: string | null; - status?: string; - isPinned?: boolean; - sortOrder?: number; - globalWeight?: number; - } }>('/api/sites/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/sites/:id', async (request, reply) => { const id = parseInt(request.params.id); if (Number.isNaN(id)) { return reply.code(400).send({ error: 'Invalid site id' }); @@ -451,20 +419,13 @@ export async function sitesRoutes(app: FastifyInstance) { return reply.code(404).send({ error: 'Site not found' }); } + const parsedBody = parseSiteUpdatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ error: parsedBody.error }); + } + const updates: any = {}; - if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) { - return reply.code(400).send({ error: 'Invalid site payload. Expected object.' }); - } - const body = request.body; - if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) { - return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' }); - } - if (body.url !== undefined && (typeof body.url !== 'string' || !body.url.trim())) { - return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' }); - } - if (body.platform !== undefined && typeof body.platform !== 'string') { - return reply.code(400).send({ error: 'Invalid platform. Expected string.' }); - } + const body = parsedBody.data; const normalizedStatus = normalizeSiteStatus(body.status); if (body.status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -498,9 +459,13 @@ export async function sitesRoutes(app: FastifyInstance) { return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); } - const canonicalPlatform = body.platform !== undefined ? normalizeSitePlatform(body.platform) : undefined; const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url; - const nextPlatform = canonicalPlatform !== undefined ? canonicalPlatform : existingSite.platform; + const nextPlatform = body.platform !== undefined + ? normalizeOptionalPlatform(body.platform) + : existingSite.platform; + if (body.platform !== undefined && !nextPlatform) { + return reply.code(400).send({ error: 'Invalid platform. Expected non-empty string.' }); + } const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform; if (siteIdentityChanged) { const siteRows = await db.select({ @@ -516,7 +481,7 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.name !== undefined) updates.name = body.name; if (body.url !== undefined) updates.url = nextUrl; - if (body.platform !== undefined) updates.platform = canonicalPlatform; + if (body.platform !== undefined) updates.platform = nextPlatform; if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; @@ -552,9 +517,14 @@ export async function sitesRoutes(app: FastifyInstance) { return { success: true }; }); - app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/sites/batch', async (request, reply) => { - const ids = normalizeBatchIds(request.body?.ids); - const action = String(request.body?.action || '').trim(); + app.post<{ Body: unknown }>('/api/sites/batch', async (request, reply) => { + const parsedBody = parseSiteBatchPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ message: parsedBody.error }); + } + + const ids = normalizeBatchIds(parsedBody.data.ids); + const action = String(parsedBody.data.action || '').trim(); if (ids.length === 0) { return reply.code(400).send({ message: 'ids is required' }); } @@ -625,7 +595,12 @@ export async function sitesRoutes(app: FastifyInstance) { }); // Update disabled models for a site (full replace) - app.put<{ Params: { id: string }; Body: { models?: string[] } }>('/api/sites/:id/disabled-models', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/sites/:id/disabled-models', async (request, reply) => { + const parsedBody = parseSiteDisabledModelsPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ error: parsedBody.error }); + } + const id = parseInt(request.params.id); if (Number.isNaN(id)) { return reply.code(400).send({ error: 'Invalid site id' }); @@ -634,7 +609,7 @@ export async function sitesRoutes(app: FastifyInstance) { if (!existingSite) { return reply.code(404).send({ error: 'Site not found' }); } - const rawModels = request.body?.models; + const rawModels = parsedBody.data.models; if (!Array.isArray(rawModels)) { return reply.code(400).send({ error: 'models must be an array of strings' }); } @@ -703,8 +678,13 @@ export async function sitesRoutes(app: FastifyInstance) { }); // Detect platform for a URL - app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => { - const result = await detectSite(request.body.url); + app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => { + const parsedBody = parseSiteDetectPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ error: parsedBody.error }); + } + + const result = await detectSite(parsedBody.data.url); return result || { error: 'Could not detect platform' }; }); } diff --git a/src/server/routes/api/tokens.batch.test.ts b/src/server/routes/api/tokens.batch.test.ts index e7868b5..1bb4c78 100644 --- a/src/server/routes/api/tokens.batch.test.ts +++ b/src/server/routes/api/tokens.batch.test.ts @@ -155,36 +155,6 @@ describe('PUT /api/channels/batch', () => { expect(dbB?.manualOverride).toBe(true); }); - it('preserves duplicate priorities in batch updates', async () => { - const channelA = await seedChannel({ priority: 9, weight: 17, manualOverride: false }); - const channelB = await seedChannel({ priority: 8, weight: 23, manualOverride: false }); - - const res = await app.inject({ - method: 'PUT', - url: '/api/channels/batch', - payload: { - updates: [ - { id: channelA.id, priority: 0 }, - { id: channelB.id, priority: 0 }, - ], - }, - }); - - expect(res.statusCode).toBe(200); - const body = res.json() as { - success: boolean; - channels: Array<{ id: number; priority: number; weight: number; manualOverride: boolean }>; - }; - expect(body.success).toBe(true); - expect(body.channels.find((channel) => channel.id === channelA.id)?.priority).toBe(0); - expect(body.channels.find((channel) => channel.id === channelB.id)?.priority).toBe(0); - - const dbA = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get(); - const dbB = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get(); - expect(dbA?.priority).toBe(0); - expect(dbB?.priority).toBe(0); - }); - it('reports the number of routes actually updated in route batch operations', async () => { const route = await db.insert(schema.tokenRoutes).values({ modelPattern: 'gpt-4o-mini', @@ -209,4 +179,37 @@ describe('PUT /api/channels/batch', () => { const updatedRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); expect(updatedRoute?.enabled).toBe(false); }); + + it('rejects route batch payloads whose ids include non-number values', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/routes/batch', + payload: { + ids: ['1'], + action: 'disable', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + success: false, + message: 'Invalid ids. Expected number[].', + }); + }); + + it('rejects non-boolean wait when rebuilding routes', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/routes/rebuild', + payload: { + wait: 'true', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + success: false, + message: 'Invalid wait. Expected boolean.', + }); + }); }); diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index ecf55ac..f37538d 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -434,6 +434,150 @@ describe('PUT /api/routes/:id route rebuild', () => { }); }); + it('rejects non-string displayName when creating explicit-group routes', async () => { + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 123, + sourceRouteIds: [sourceRoute.id], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid displayName. Expected string or null.', + }); + }); + + it('rejects non-number sourceRouteIds when creating explicit-group routes', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: ['1'], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid sourceRouteIds. Expected number[].', + }); + }); + + it('rejects non-boolean enabled when updating routes', async () => { + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o', + enabled: true, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/routes/${route.id}`, + payload: { + enabled: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid enabled. Expected boolean.', + }); + }); + + it('rejects non-number accountId when adding route channels', async () => { + const seeded = await seedAccountWithToken('gpt-4o-mini'); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/routes/${route.id}/channels`, + payload: { + accountId: String(seeded.account.id), + tokenId: seeded.token.id, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid accountId. Expected positive number.', + }); + }); + + it('rejects non-boolean enabled when updating channels', async () => { + const seeded = await seedAccountWithToken('gpt-4o-mini'); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: seeded.account.id, + tokenId: seeded.token.id, + sourceModel: 'gpt-4o-mini', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/channels/${channel.id}`, + payload: { + enabled: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid enabled. Expected boolean.', + }); + }); + + it('rejects non-number accountId when batch-adding route channels', async () => { + const seeded = await seedAccountWithToken('gpt-4o-mini'); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/routes/${route.id}/channels/batch`, + payload: { + channels: [ + { + accountId: String(seeded.account.id), + tokenId: seeded.token.id, + }, + ], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: 'Invalid channels[].accountId. Expected positive number.', + }); + }); + it('prefers an exact route over a colliding explicit-group display name', async () => { const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 5ab39a3..19de101 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -20,6 +20,15 @@ import { saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; +import { + parseRouteChannelBatchCreatePayload, + parseRouteChannelCreatePayload, + parseRouteChannelUpdatePayload, + parseRouteRebuildPayload, + parseTokenRouteBatchPayload, + parseTokenRouteCreatePayload, + parseTokenRouteUpdatePayload, +} from '../../contracts/tokenRoutePayloads.js'; function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); @@ -760,9 +769,14 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Batch add channels to a route - app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { + app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels/batch', async (request, reply) => { + const parsedBody = parseRouteChannelBatchCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const routeId = parseInt(request.params.id, 10); - const body = request.body; + const body = parsedBody.data; const route = await getRouteWithSources(routeId); if (!route) { @@ -772,13 +786,9 @@ export async function tokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); } - if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) { - return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' }); - } - - const existingChannels = await db.select().from(schema.routeChannels) - .where(eq(schema.routeChannels.routeId, routeId)) - .all(); + const existingChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, routeId)) + .all(); const existingPairs = new Set( existingChannels.map((channel) => { const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0; @@ -787,19 +797,14 @@ export async function tokensRoutes(app: FastifyInstance) { }), ); - let created = 0; - let skipped = 0; - const errors: string[] = []; - - for (const item of body.channels) { - if (!item?.accountId || typeof item.accountId !== 'number') { - errors.push('无效的 accountId'); - continue; - } - - const sourceModel = typeof item.sourceModel === 'string' - ? item.sourceModel.trim() - : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); + let created = 0; + let skipped = 0; + const errors: string[] = []; + + for (const item of body.channels) { + const sourceModel = typeof item.sourceModel === 'string' + ? item.sourceModel.trim() + : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); const effectiveTokenId = item.tokenId ?? await getDefaultTokenId(item.accountId); if (item.tokenId && !await checkTokenBelongsToAccount(item.tokenId, item.accountId)) { @@ -955,8 +960,13 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Create a route - app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => { - const body = request.body; + app.post<{ Body: unknown }>('/api/routes', async (request, reply) => { + const parsedBody = parseTokenRouteCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; const routeMode = normalizeRouteMode(body.routeMode); const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); @@ -1014,9 +1024,14 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Update a route - app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { + app.put<{ Params: { id: string }; Body: unknown }>('/api/routes/:id', async (request, reply) => { + const parsedBody = parseTokenRouteUpdatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const id = parseInt(request.params.id, 10); - const body = request.body as Record; + const body = parsedBody.data; const existingRoute = await getRouteWithSources(id); if (!existingRoute) { return reply.code(404).send({ success: false, message: '路由不存在' }); @@ -1113,11 +1128,12 @@ export async function tokensRoutes(app: FastifyInstance) { // Batch update routes (enable/disable) - app.post<{ Body: { ids: number[]; action: 'enable' | 'disable' } }>('/api/routes/batch', async (request, reply) => { - const body = request.body; - if (!body || typeof body !== 'object') { - return reply.code(400).send({ success: false, message: '请求体必须是对象' }); + app.post<{ Body: unknown }>('/api/routes/batch', async (request, reply) => { + const parsedBody = parseTokenRouteBatchPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); } + const body = parsedBody.data; const action = body.action; if (action !== 'enable' && action !== 'disable') { return reply.code(400).send({ success: false, message: 'action 必须是 enable 或 disable' }); @@ -1154,9 +1170,14 @@ export async function tokensRoutes(app: FastifyInstance) { return { success: true, updatedCount: Number(updateResult?.changes || 0) }; }); // Add a channel to a route - app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { + app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels', async (request, reply) => { + const parsedBody = parseRouteChannelCreatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + const routeId = parseInt(request.params.id, 10); - const body = request.body; + const body = parsedBody.data; const route = await getRouteWithSources(routeId); if (!route) { @@ -1247,9 +1268,14 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Update a channel - app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { - const channelId = parseInt(request.params.channelId, 10); - const body = request.body as Record; + app.put<{ Params: { channelId: string }; Body: unknown }>('/api/channels/:channelId', async (request, reply) => { + const parsedBody = parseRouteChannelUpdatePayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const channelId = parseInt(request.params.channelId, 10); + const body = parsedBody.data; const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); if (!channel) { @@ -1282,9 +1308,10 @@ export async function tokensRoutes(app: FastifyInstance) { else updates.sourceModel = String(body.sourceModel).trim() || null; } - for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { - if (body[key] !== undefined) updates[key] = body[key]; - } + if (body.priority !== undefined) updates.priority = body.priority; + if (body.weight !== undefined) updates.weight = body.weight; + if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.tokenId !== undefined) updates.tokenId = body.tokenId; await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await clearRouteDecisionSnapshot(channel.routeId); @@ -1307,17 +1334,22 @@ export async function tokensRoutes(app: FastifyInstance) { }); // Rebuild routes/channels from model availability. - app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { - const body = (request.body || {}) as { refreshModels?: boolean }; + app.post<{ Body: unknown }>('/api/routes/rebuild', async (request, reply) => { + const parsedBody = parseRouteRebuildPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const body = parsedBody.data; if (body.refreshModels === false) { const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true, rebuild }; } - - if ((request.body as { wait?: boolean } | undefined)?.wait) { + + if (body.wait) { const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); - return { success: true, ...result }; - } + return { success: true, ...result }; + } const { task, reused } = startBackgroundTask( { diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index 9e8c9a0..54c584a 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -256,6 +256,75 @@ describe('update center routes', () => { }); }); + it('rejects malformed config, deploy, and rollback payloads at the route boundary', async () => { + const invalidConfigResponse = await app.inject({ + method: 'PUT', + url: '/api/update-center/config', + payload: { + enabled: 'false', + }, + }); + expect(invalidConfigResponse.statusCode).toBe(400); + expect(invalidConfigResponse.json()).toMatchObject({ + success: false, + message: 'Invalid enabled. Expected boolean.', + }); + + const invalidDeployResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + targetTag: 123, + }, + }); + expect(invalidDeployResponse.statusCode).toBe(400); + expect(invalidDeployResponse.json()).toMatchObject({ + success: false, + message: 'Invalid targetTag. Expected string.', + }); + + const invalidRollbackResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/rollback', + payload: { + targetRevision: 123, + }, + }); + expect(invalidRollbackResponse.statusCode).toBe(400); + expect(invalidRollbackResponse.json()).toMatchObject({ + success: false, + message: 'Invalid targetRevision. Expected string.', + }); + }); + + it('rejects invalid update-center source enums at the route boundary', async () => { + const invalidConfigResponse = await app.inject({ + method: 'PUT', + url: '/api/update-center/config', + payload: { + defaultDeploySource: 'nightly', + }, + }); + expect(invalidConfigResponse.statusCode).toBe(400); + expect(invalidConfigResponse.json()).toMatchObject({ + success: false, + message: 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.', + }); + + const invalidDeployResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/deploy', + payload: { + source: 'nightly', + }, + }); + expect(invalidDeployResponse.statusCode).toBe(400); + expect(invalidDeployResponse.json()).toMatchObject({ + success: false, + message: 'Invalid source. Expected docker-hub-tag/github-release.', + }); + }); + it('uses the shared config helper token when request-time env lookup is unavailable', async () => { fetchLatestStableGitHubReleaseMock.mockResolvedValue({ source: 'github-release', diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts index dfb7955..e0dfe16 100644 --- a/src/server/routes/api/updateCenter.ts +++ b/src/server/routes/api/updateCenter.ts @@ -31,6 +31,11 @@ import { UPDATE_CENTER_DEPLOY_DEDUPE_KEY, UPDATE_CENTER_DEPLOY_TASK_TYPE, } from '../../services/updateCenterTaskConstants.js'; +import { + parseUpdateCenterConfigPayload, + parseUpdateCenterDeployPayload, + parseUpdateCenterRollbackPayload, +} from '../../contracts/supportRoutePayloads.js'; type DeployBody = { source?: UpdateCenterVersionSource; @@ -84,8 +89,13 @@ export async function updateCenterRoutes(app: FastifyInstance) { return (await refreshUpdateCenterStatusCache()).status; }); - app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => { - const next = normalizeUpdateCenterConfig(request.body || getDefaultUpdateCenterConfig()); + app.put<{ Body: unknown }>('/api/update-center/config', async (request, reply) => { + const parsedBody = parseUpdateCenterConfigPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ success: false, message: parsedBody.error }); + } + + const next = normalizeUpdateCenterConfig(parsedBody.data || getDefaultUpdateCenterConfig()); const saved = await saveUpdateCenterConfig(next); return { success: true, @@ -93,7 +103,16 @@ export async function updateCenterRoutes(app: FastifyInstance) { }; }); - app.post<{ Body: DeployBody }>('/api/update-center/deploy', async (request, reply) => { + app.post<{ Body: unknown }>('/api/update-center/deploy', async (request, reply) => { + const parsedBody = parseUpdateCenterDeployPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const body = parsedBody.data; const config = await loadUpdateCenterConfig(); try { assertDeployableConfig(config); @@ -105,13 +124,13 @@ export async function updateCenterRoutes(app: FastifyInstance) { } const helperToken = getUpdateCenterHelperToken(); - const source = request.body?.source === 'docker-hub-tag' + const source = body.source === 'docker-hub-tag' ? 'docker-hub-tag' - : request.body?.source === 'github-release' + : body.source === 'github-release' ? 'github-release' : config.defaultDeploySource; - const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim(); - const targetDigest = normalizeUpdateCenterTargetDigest(request.body?.targetDigest); + const targetTag = String(body.targetTag || body.targetVersion || '').trim(); + const targetDigest = normalizeUpdateCenterTargetDigest(body.targetDigest); if (!targetTag) { return reply.code(400).send({ success: false, @@ -175,7 +194,16 @@ export async function updateCenterRoutes(app: FastifyInstance) { }); }); - app.post<{ Body: RollbackBody }>('/api/update-center/rollback', async (request, reply) => { + app.post<{ Body: unknown }>('/api/update-center/rollback', async (request, reply) => { + const parsedBody = parseUpdateCenterRollbackPayload(request.body); + if (!parsedBody.success) { + return reply.code(400).send({ + success: false, + message: parsedBody.error, + }); + } + + const body = parsedBody.data; const config = await loadUpdateCenterConfig(); try { assertHelperConfig(config); @@ -186,7 +214,7 @@ export async function updateCenterRoutes(app: FastifyInstance) { }); } - const targetRevision = String(request.body?.targetRevision || '').trim(); + const targetRevision = String(body.targetRevision || '').trim(); if (!targetRevision) { return reply.code(400).send({ success: false, diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index dfb6895..f819894 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -95,7 +95,7 @@ type UndiciConnectOptions = { httpSocket?: Socket; }; -function normalizeSiteUrl(value: string): string { +export function normalizeSiteUrl(value: string): string { const trimmed = (value || '').trim(); if (!trimmed) return '';