forked from boos/BoosAPI
refactor: remove deprecated apiKey from site management and update related functionality
- Removed apiKey from SiteRow type and related forms in Sites component. - Updated site saving logic to handle site creation without apiKey. - Adjusted tests to ensure apiKey is not included in site editor state. - Refactored Tokens component to support embedded mode and redirect logic. - Added migration tests for site apiKey to account migration service. - Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens. - Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
|
||||
const getApiTokensMock = vi.fn();
|
||||
const getApiTokenMock = vi.fn();
|
||||
@@ -137,6 +138,80 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(tokenRows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects sync and token management for apikey connections', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
.set({
|
||||
apiToken: 'sk-proxy-only',
|
||||
checkinEnabled: false,
|
||||
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
const syncResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
expect(syncResponse.statusCode).toBe(400);
|
||||
expect(syncResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持同步账号令牌',
|
||||
});
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
name: 'should-fail',
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(400);
|
||||
expect(createResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持创建账号令牌',
|
||||
});
|
||||
|
||||
const groupsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/account-tokens/groups/${account.id}`,
|
||||
});
|
||||
expect(groupsResponse.statusCode).toBe(400);
|
||||
expect(groupsResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持拉取账号令牌分组',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides legacy mirrored tokens for apikey connections from list API', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
.set({
|
||||
apiToken: 'sk-hidden-legacy',
|
||||
checkinEnabled: false,
|
||||
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-hidden-legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/account-tokens',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
|
||||
const disabled = await seedAccount({ siteStatus: 'disabled' });
|
||||
const active = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
syncTokensFromUpstream,
|
||||
} from '../../services/accountTokenService.js';
|
||||
import { getAdapter } from '../../services/platforms/index.js';
|
||||
import { resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
@@ -82,6 +82,12 @@ function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -169,6 +175,20 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
};
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return {
|
||||
...base,
|
||||
status: 'skipped',
|
||||
reason: 'apikey_connection',
|
||||
message: 'apikey connection does not support account tokens',
|
||||
synced: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
total: 0,
|
||||
defaultTokenId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!row.accounts.accessToken) {
|
||||
if (row.accounts.apiToken) {
|
||||
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
|
||||
@@ -341,6 +361,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持创建账号令牌' });
|
||||
}
|
||||
|
||||
const tokenValue = (body.token || '').trim();
|
||||
if (tokenValue) {
|
||||
const now = new Date().toISOString();
|
||||
@@ -481,6 +505,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.accountId)).get();
|
||||
if (!owner) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
if (isApiKeyConnection(owner)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
|
||||
@@ -525,7 +557,18 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
if (Number.isNaN(tokenId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
const success = setDefaultToken(tokenId);
|
||||
const tokenRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!tokenRow) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, tokenRow.accountId)).get();
|
||||
if (!owner) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
if (isApiKeyConnection(owner)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
const success = await setDefaultToken(tokenId);
|
||||
if (!success) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
@@ -548,6 +591,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
|
||||
return {
|
||||
success: true,
|
||||
@@ -573,6 +620,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持拉取账号令牌分组' });
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const adapter = getAdapter(site.platform);
|
||||
@@ -612,6 +663,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const existing = row.account_tokens;
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
@@ -657,6 +712,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
|
||||
const result = await executeAccountTokenSync(row);
|
||||
appendTokenSyncEvent(result);
|
||||
if (result.status === 'skipped' && result.reason === 'apikey_connection') {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持同步账号令牌' });
|
||||
}
|
||||
if (result.status === 'failed' && result.reason === 'unsupported_platform') {
|
||||
return reply.code(400).send({ success: false, message: result.message });
|
||||
}
|
||||
@@ -727,6 +785,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
success: true,
|
||||
token: row
|
||||
? (() => {
|
||||
if (isApiKeyConnection(row.accounts)) return null;
|
||||
const { token: rawToken, ...meta } = row.account_tokens;
|
||||
return { ...meta, tokenMasked: maskToken(rawToken, row.sites.platform) };
|
||||
})()
|
||||
|
||||
@@ -122,6 +122,9 @@ describe('accounts credential mode', () => {
|
||||
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
|
||||
expect(accounts[0]?.checkinEnabled).toBe(false);
|
||||
|
||||
const accountTokens = await db.select().from(schema.accountTokens).all();
|
||||
expect(accountTokens).toHaveLength(0);
|
||||
|
||||
const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
|
||||
expect(parsedExtra.credentialMode).toBe('apikey');
|
||||
});
|
||||
|
||||
@@ -1058,7 +1058,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
||||
}
|
||||
|
||||
if (apiToken) {
|
||||
if (tokenType === 'session' && apiToken) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
@@ -1161,7 +1161,15 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
const nextAccessToken = typeof updates.accessToken === 'string' ? updates.accessToken : account.accessToken;
|
||||
const nextExtraConfig = typeof updates.extraConfig === 'string' ? updates.extraConfig : account.extraConfig;
|
||||
const explicitNextMode = getCredentialModeFromExtraConfig(nextExtraConfig);
|
||||
const nextCredentialMode =
|
||||
explicitNextMode && explicitNextMode !== 'auto'
|
||||
? explicitNextMode
|
||||
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
|
||||
|
||||
if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
@@ -1253,4 +1261,3 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
@@ -104,7 +103,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
const { name, url, platform, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -145,7 +144,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
apiKey,
|
||||
proxyUrl: parsedProxyUrl.proxyUrl,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
status: normalizedStatus ?? 'active',
|
||||
@@ -170,7 +168,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name?: string;
|
||||
url?: string;
|
||||
platform?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
@@ -218,7 +215,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
||||
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
|
||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||
|
||||
@@ -363,4 +363,81 @@ describe('/api/models/token-candidates', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-e',
|
||||
url: 'https://site-e.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
apiKey: 'sk-site-fallback',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'erin',
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-default-e',
|
||||
tokenGroup: 'default',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: token.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'claude-opus-4-6',
|
||||
quotaType: 0,
|
||||
modelDescription: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
ownerBy: null,
|
||||
enableGroups: ['default', 'vip'],
|
||||
groupPricing: {},
|
||||
},
|
||||
],
|
||||
groupRatio: { default: 1, vip: 2 },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/models/token-candidates',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
site: {
|
||||
id: site.id,
|
||||
url: site.url,
|
||||
platform: site.platform,
|
||||
},
|
||||
account: {
|
||||
id: account.id,
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
},
|
||||
});
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,7 +332,6 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
id: row.sites.id,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
apiKey: row.sites.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: row.accounts.id,
|
||||
@@ -663,7 +662,6 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
id: row.sites.id,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
apiKey: row.sites.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: row.accounts.id,
|
||||
|
||||
@@ -955,7 +955,6 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
id: context.site.id,
|
||||
url: context.site.url,
|
||||
platform: context.site.platform,
|
||||
apiKey: context.site.apiKey ?? null,
|
||||
},
|
||||
account: {
|
||||
id: context.account.id,
|
||||
|
||||
Reference in New Issue
Block a user