1
0
forked from boos/BoosAPI

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
This commit is contained in:
Cita
2026-04-01 12:52:55 +08:00
committed by GitHub
parent 6a55effd3b
commit 8c3be1a613
43 changed files with 2765 additions and 404 deletions
+11 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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<typeof accountTokenBatchPayloadSchema>;
export type AccountTokenCreatePayload = z.output<typeof accountTokenCreatePayloadSchema>;
export type AccountTokenSyncAllPayload = z.output<typeof accountTokenSyncAllPayloadSchema>;
export type AccountTokenUpdatePayload = z.output<typeof accountTokenUpdatePayloadSchema>;
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,
};
}
@@ -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<typeof accountBatchPayloadSchema>;
export type AccountCreatePayload = z.output<typeof accountCreatePayloadSchema>;
export type AccountHealthRefreshPayload = z.output<typeof accountHealthRefreshPayloadSchema>;
export type AccountLoginPayload = z.output<typeof accountLoginPayloadSchema>;
export type AccountManualModelsPayload = z.output<typeof accountManualModelsPayloadSchema>;
export type AccountRebindSessionPayload = z.output<typeof accountRebindSessionPayloadSchema>;
export type AccountUpdatePayload = z.output<typeof accountUpdatePayloadSchema>;
export type AccountVerifyTokenPayload = z.output<typeof accountVerifyTokenPayloadSchema>;
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<T>(schema: z.ZodType<T>, 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);
}
@@ -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<typeof downstreamApiKeyBatchPayloadSchema>;
export type DownstreamApiKeyPayload = z.output<typeof downstreamApiKeyPayloadSchema>;
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<T extends z.ZodTypeAny>(
schema: T,
input: unknown,
): { success: true; data: z.output<T> } | { 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);
}
@@ -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<typeof backupWebdavConfigPayloadSchema>;
export type BackupWebdavExportPayload = z.output<typeof backupWebdavExportPayloadSchema>;
export type BackupImportPayload = z.output<typeof backupImportPayloadSchema>;
export type DatabaseMigrationPayload = z.output<typeof databaseMigrationPayloadSchema>;
export type RuntimeSettingsPayload = z.output<typeof runtimeSettingsPayloadSchema>;
export type SystemProxyTestPayload = z.output<typeof systemProxyTestPayloadSchema>;
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,
};
}
+157
View File
@@ -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<typeof siteBatchPayloadSchema>;
export type SiteCreatePayload = z.output<typeof siteCreatePayloadSchema>;
export type SiteDetectPayload = z.output<typeof siteDetectPayloadSchema>;
export type SiteDisabledModelsPayload = z.output<typeof siteDisabledModelsPayloadSchema>;
export type SiteUpdatePayload = z.output<typeof siteUpdatePayloadSchema>;
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,
};
}
@@ -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<typeof authChangePayloadSchema>;
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
export type UpdateCenterRollbackPayload = z.output<typeof updateCenterRollbackPayloadSchema>;
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<T extends z.ZodTypeAny>(
schema: T,
input: unknown,
): { success: true; data: z.output<T> } | { 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);
}
+193
View File
@@ -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<typeof routeChannelBatchCreatePayloadSchema>;
export type RouteChannelCreatePayload = z.output<typeof routeChannelCreatePayloadSchema>;
export type RouteChannelUpdatePayload = z.output<typeof routeChannelUpdatePayloadSchema>;
export type RouteRebuildPayload = z.output<typeof routeRebuildPayloadSchema>;
export type TokenRouteBatchPayload = z.output<typeof tokenRouteBatchPayloadSchema>;
export type TokenRouteCreatePayload = z.output<typeof tokenRouteCreatePayloadSchema>;
export type TokenRouteUpdatePayload = z.output<typeof tokenRouteUpdatePayloadSchema>;
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<T extends z.ZodTypeAny>(
schema: T,
input: unknown,
): { success: true; data: z.output<T> } | { 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);
}
@@ -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');
});
});
@@ -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({
+50 -29
View File
@@ -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<string, unknown> = { 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 };
}
@@ -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');
});
});
@@ -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');
});
});
@@ -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',
@@ -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.',
});
});
});
@@ -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[].',
});
});
});
@@ -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 无效'));
+73 -31
View File
@@ -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<string, unknown>;
const parsedBody = parseAccountUpdatePayload(request.body);
if (!parsedBody.success) {
return reply.code(400).send({ message: parsedBody.error });
}
const body = parsedBody.data as Record<string, unknown>;
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) {
}
});
}
+66
View File
@@ -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.',
});
});
});
+8 -2
View File
@@ -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: '请填写所有字段' });
@@ -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',
+44 -60
View File
@@ -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<typeof normalizeDownstreamApiKeyPayload>;
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<typeof normalizeDownstreamApiKeyPayload>;
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)) {
+53
View File
@@ -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.',
});
});
});
+8 -2
View File
@@ -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 };
+49
View File
@@ -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',
+33 -12
View File
@@ -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' });
}
@@ -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();
}
});
});
@@ -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');
});
});
@@ -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',
@@ -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',
@@ -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'),
+81 -17
View File
@@ -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<string, unknown> } }>('/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;
+16
View File
@@ -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[].',
});
});
});
@@ -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',
+66 -62
View File
@@ -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',
+62 -82
View File
@@ -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' };
});
}
+33 -30
View File
@@ -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.',
});
});
});
@@ -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');
+76 -44
View File
@@ -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<string>(
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<string, unknown>;
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<string, unknown>;
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(
{
@@ -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',
+37 -9
View File
@@ -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,
+1 -1
View File
@@ -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 '';