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
@@ -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,