fix edit payload clearing boundaries (#381)
This commit is contained in:
@@ -21,9 +21,9 @@ const accountUpdatePayloadSchema = z.object({
|
||||
apiToken: z.union([z.string(), z.null()]).optional(),
|
||||
status: z.string().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
unitCost: z.number().optional(),
|
||||
unitCost: z.union([z.number(), z.null()]).optional(),
|
||||
extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
refreshToken: z.union([z.string(), z.null()]).optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
@@ -98,6 +98,9 @@ function formatAccountsPayloadError(error: z.ZodError): string {
|
||||
if (firstPath === 'checkinEnabled') {
|
||||
return 'Invalid checkinEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'unitCost') {
|
||||
return 'Invalid unitCost. Expected number or null.';
|
||||
}
|
||||
if (firstPath === 'credentialMode') {
|
||||
return 'Invalid credentialMode. Expected auto/session/apikey.';
|
||||
}
|
||||
@@ -123,10 +126,10 @@ function formatAccountsPayloadError(error: z.ZodError): string {
|
||||
return 'Invalid platformUserId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'refreshToken') {
|
||||
return 'Invalid refreshToken. Expected string.';
|
||||
return 'Invalid refreshToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tokenExpiresAt') {
|
||||
return 'Invalid tokenExpiresAt. Expected number or string.';
|
||||
return 'Invalid tokenExpiresAt. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return '账号 ID 无效';
|
||||
|
||||
@@ -159,6 +159,39 @@ describe('account token batch routes', () => {
|
||||
expect(remaining.map((item) => item.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('accepts the edit-panel payload when updating account token metadata without changing token value', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/account-tokens/1',
|
||||
payload: {
|
||||
name: 'token-1-updated',
|
||||
group: 'default',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
token: {
|
||||
id: 1,
|
||||
name: 'token-1-updated',
|
||||
tokenGroup: 'default',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
},
|
||||
});
|
||||
|
||||
const row = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, 1)).get();
|
||||
expect(row).toMatchObject({
|
||||
name: 'token-1-updated',
|
||||
tokenGroup: 'default',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid account token batch action', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -415,7 +415,8 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
method: 'PUT',
|
||||
url: `/api/accounts/${account.id}`,
|
||||
payload: {
|
||||
refreshToken: '',
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
},
|
||||
});
|
||||
expect(clearResponse.statusCode).toBe(200);
|
||||
@@ -427,6 +428,54 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
expect(parsedCleared.sub2apiAuth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts nullable optional fields from the edit panel payload', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Editable Site',
|
||||
url: 'https://editable.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'before-edit',
|
||||
accessToken: 'access-token',
|
||||
status: 'active',
|
||||
unitCost: 25,
|
||||
extraConfig: JSON.stringify({
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/accounts/${account.id}`,
|
||||
payload: {
|
||||
username: 'after-edit',
|
||||
status: 'disabled',
|
||||
checkinEnabled: false,
|
||||
unitCost: null,
|
||||
accessToken: 'access-token-updated',
|
||||
apiToken: null,
|
||||
isPinned: false,
|
||||
refreshToken: null,
|
||||
tokenExpiresAt: null,
|
||||
proxyUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
expect(updated).toMatchObject({
|
||||
username: 'after-edit',
|
||||
status: 'disabled',
|
||||
checkinEnabled: false,
|
||||
unitCost: null,
|
||||
accessToken: 'access-token-updated',
|
||||
apiToken: null,
|
||||
isPinned: false,
|
||||
});
|
||||
expect(JSON.parse(updated?.extraConfig || '{}')).not.toHaveProperty('proxyUrl');
|
||||
});
|
||||
|
||||
it('does not refresh models for pin-only account edits', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Pinned Site',
|
||||
|
||||
@@ -163,6 +163,67 @@ describe('downstream api keys routes', () => {
|
||||
expect(listRes.json()).toMatchObject({ success: true, items: [] });
|
||||
});
|
||||
|
||||
it('clears nullable editor fields when updating a downstream api key', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/downstream-keys',
|
||||
payload: {
|
||||
name: 'editor-key',
|
||||
key: 'sk-editor-key-001',
|
||||
description: 'editor consumer',
|
||||
groupName: '项目A',
|
||||
enabled: true,
|
||||
expiresAt: '2026-12-31T00:00:00.000Z',
|
||||
maxCost: 12.5,
|
||||
maxRequests: 500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const keyId = (createRes.json() as { item: { id: number } }).item.id;
|
||||
|
||||
const updateRes = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/downstream-keys/${keyId}`,
|
||||
payload: {
|
||||
name: 'editor-key-updated',
|
||||
key: 'sk-editor-key-001',
|
||||
description: '',
|
||||
groupName: null,
|
||||
enabled: true,
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
maxRequests: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateRes.statusCode).toBe(200);
|
||||
expect(updateRes.json()).toMatchObject({
|
||||
success: true,
|
||||
item: {
|
||||
id: keyId,
|
||||
name: 'editor-key-updated',
|
||||
description: null,
|
||||
groupName: null,
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
maxRequests: null,
|
||||
},
|
||||
});
|
||||
|
||||
const row = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, keyId))
|
||||
.get();
|
||||
expect(row).toMatchObject({
|
||||
name: 'editor-key-updated',
|
||||
description: null,
|
||||
groupName: null,
|
||||
expiresAt: null,
|
||||
maxCost: null,
|
||||
maxRequests: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports batch enable/disable/reset/delete operations', async () => {
|
||||
const inserted = await db.insert(schema.downstreamApiKeys).values([
|
||||
{
|
||||
|
||||
@@ -536,21 +536,22 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
|
||||
const existingView = toDownstreamApiKeyPolicyView(existing);
|
||||
const body = parsedBody.data;
|
||||
const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(body, key);
|
||||
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
|
||||
try {
|
||||
normalized = normalizeDownstreamApiKeyPayload({
|
||||
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,
|
||||
name: hasOwn('name') ? body.name : existing.name,
|
||||
key: hasOwn('key') ? body.key : existing.key,
|
||||
description: hasOwn('description') ? body.description : existing.description,
|
||||
groupName: hasOwn('groupName') ? body.groupName : existing.groupName,
|
||||
tags: hasOwn('tags') ? body.tags : existingView.tags,
|
||||
enabled: hasOwn('enabled') ? body.enabled : existing.enabled,
|
||||
expiresAt: hasOwn('expiresAt') ? body.expiresAt : existing.expiresAt,
|
||||
maxCost: hasOwn('maxCost') ? body.maxCost : existing.maxCost,
|
||||
maxRequests: hasOwn('maxRequests') ? body.maxRequests : existing.maxRequests,
|
||||
supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels,
|
||||
allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds,
|
||||
siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
@@ -50,4 +51,34 @@ describe('monitor routes', () => {
|
||||
message: 'Invalid ldohCookie. Expected string or null.',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null monitor cookie payloads and clears the stored cookie', async () => {
|
||||
const saveResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/monitor/config',
|
||||
payload: {
|
||||
ldohCookie: 'ld_auth_session=abcdefghijklmnopqrstuvwxyz',
|
||||
},
|
||||
});
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
|
||||
const clearResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/monitor/config',
|
||||
payload: {
|
||||
ldohCookie: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(clearResponse.statusCode).toBe(200);
|
||||
expect(clearResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
ldohCookieConfigured: false,
|
||||
});
|
||||
|
||||
const saved = await db.select().from(schema.settings)
|
||||
.where(eq(schema.settings.key, 'monitor_ldoh_cookie'))
|
||||
.get();
|
||||
expect(saved?.value).toBe('""');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,6 +215,49 @@ describe('sites proxy settings', () => {
|
||||
expect(payload.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('clears optional editor fields when updating a site with empty strings', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'editable-site',
|
||||
url: 'https://editable-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
useSystemProxy: true,
|
||||
customHeaders: JSON.stringify({
|
||||
'x-site-scope': 'internal',
|
||||
}),
|
||||
externalCheckinUrl: 'https://checkin.example.com/welfare',
|
||||
},
|
||||
});
|
||||
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: {
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
customHeaders: '',
|
||||
externalCheckinUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json() as {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
};
|
||||
expect(payload.proxyUrl).toBeNull();
|
||||
expect(payload.useSystemProxy).toBe(false);
|
||||
expect(payload.customHeaders).toBeNull();
|
||||
expect(payload.externalCheckinUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a conflict response when updating a site to an existing platform and url', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -578,6 +578,54 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null tokenId when updating a channel and falls back to the account default token', async () => {
|
||||
const seeded = await seedAccountWithToken('gpt-4o-mini');
|
||||
const alternateToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: seeded.account.id,
|
||||
name: 'token-alt',
|
||||
token: 'sk-token-alt',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
}).returning().get();
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: alternateToken.id,
|
||||
modelName: 'gpt-4o-mini',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
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: alternateToken.id,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: true,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/channels/${channel.id}`,
|
||||
payload: {
|
||||
tokenId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
id: channel.id,
|
||||
tokenId: seeded.token.id,
|
||||
});
|
||||
|
||||
const updated = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).get();
|
||||
expect(updated?.tokenId).toBe(seeded.token.id);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -1360,7 +1360,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
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;
|
||||
if (body.tokenId !== undefined) updates.tokenId = nextTokenId;
|
||||
|
||||
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
|
||||
Reference in New Issue
Block a user