forked from boos/BoosAPI
feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks feat: create main application logic for desktop with window management, server handling, and auto-updater test: add unit tests for desktop runtime helpers feat: implement desktop server environment configuration and health check test: add tests for server configuration and desktop routes feat: add native module guard for better-sqlite3 compatibility test: add tests for native module guard functionality test: add comprehensive tests for account verification feedback handling feat: implement account verification feedback helpers chore: add TypeScript configuration for desktop builds
This commit is contained in:
@@ -207,6 +207,53 @@ describe('accounts health refresh runtime state', () => {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
it('skips runtime refresh for proxy-only accounts', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Proxy Site',
|
||||
url: 'https://proxy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: 'api-key-token',
|
||||
status: 'expired',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'apikey',
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/health/refresh',
|
||||
payload: { accountId: account.id, wait: true },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
success: boolean;
|
||||
summary: {
|
||||
total: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
results: Array<{ status: string; state: string; message: string }>;
|
||||
};
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.summary).toMatchObject({
|
||||
total: 1,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(body.results[0]).toMatchObject({
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
});
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes the background refresh-all task after the 10 second site timeout instead of staying in progress', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,8 @@ type AccountCapabilities = {
|
||||
proxyOnly: boolean;
|
||||
};
|
||||
|
||||
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
|
||||
|
||||
function hasSessionTokenValue(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -126,6 +128,8 @@ type LoginFailureInfo = {
|
||||
};
|
||||
|
||||
const ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_DIAG_TIMEOUT_MS = 2_500;
|
||||
|
||||
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
|
||||
const raw = (message || '').trim();
|
||||
@@ -184,12 +188,53 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
|
||||
}
|
||||
}
|
||||
|
||||
function isVerificationTimeoutError(error: unknown): boolean {
|
||||
const name = typeof error === 'object' && error && 'name' in error
|
||||
? String((error as { name?: unknown }).name || '')
|
||||
: '';
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message || '')
|
||||
: String(error || '');
|
||||
const lowered = `${name} ${message}`.toLowerCase();
|
||||
return lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('abort');
|
||||
}
|
||||
|
||||
function resolveUserIdFailureReason(message: string, hasProvidedUserId: boolean): VerifyFailureReason {
|
||||
const lowered = String(message || '').trim().toLowerCase();
|
||||
if (!lowered) return null;
|
||||
|
||||
if (
|
||||
lowered.includes('mismatch')
|
||||
|| lowered.includes('not match')
|
||||
|| lowered.includes('invalid user id')
|
||||
|| lowered.includes('wrong user id')
|
||||
) {
|
||||
return 'invalid-user-id';
|
||||
}
|
||||
|
||||
if (
|
||||
lowered.includes('missing new-api-user')
|
||||
|| lowered.includes('new-api-user required')
|
||||
|| lowered.includes('requires user id')
|
||||
|| lowered.includes('missing user id')
|
||||
) {
|
||||
return 'needs-user-id';
|
||||
}
|
||||
|
||||
if (lowered.includes('new-api-user') || lowered.includes('user id')) {
|
||||
return hasProvidedUserId ? 'invalid-user-id' : 'needs-user-id';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
const capabilities = buildCapabilitiesForAccount(row.accounts);
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'disabled',
|
||||
reason: '账号或站点已禁用',
|
||||
@@ -202,7 +247,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
status: 'skipped',
|
||||
state: 'disabled',
|
||||
message: '账号或站点已禁用',
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (capabilities.proxyOnly) {
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
message: '仅代理账号不支持会话健康检查',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -218,6 +274,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
accountStatus: refreshedAccount?.status || row.accounts.status,
|
||||
siteStatus: row.sites.status,
|
||||
extraConfig: refreshedAccount?.extraConfig ?? row.accounts.extraConfig,
|
||||
sessionCapable: capabilities.canRefreshBalance,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -333,6 +390,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
accountStatus: r.accounts.status,
|
||||
siteStatus: r.sites.status,
|
||||
extraConfig: r.accounts.extraConfig,
|
||||
sessionCapable: buildCapabilitiesFromCredentialMode(
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
).canRefreshBalance,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -464,9 +525,119 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
||||
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
const ct = (contentType || '').toLowerCase();
|
||||
if (
|
||||
!skipRawShieldDetection
|
||||
&& ct.includes('text/html')
|
||||
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
|
||||
) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
const { fetch } = await import('undici');
|
||||
const candidates = new Set<string>();
|
||||
const raw = accessToken.startsWith('Bearer ') ? accessToken.slice(7).trim() : accessToken;
|
||||
if (raw) {
|
||||
if (raw.includes('=')) candidates.add(raw);
|
||||
candidates.add(`session=${raw}`);
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
headerVariants.push({
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
if (reason) return reason;
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
const buildVerificationFailureResponse = (failureReason: VerifyFailureReason) => {
|
||||
if (failureReason === 'needs-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
message: 'This site requires a user ID. Please fill in your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
shieldBlocked: true,
|
||||
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
|
||||
const preflightReason = await diagnoseVerificationFailure();
|
||||
if (preflightReason === 'needs-user-id') {
|
||||
return buildVerificationFailureResponse(preflightReason);
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialMode === 'apikey') {
|
||||
try {
|
||||
const models = await adapter.getModels(site.url, accessToken, platformUserId);
|
||||
const models = await withTimeout(
|
||||
() => adapter.getModels(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
@@ -481,6 +652,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
models: availableModels.slice(0, 10),
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err?.message || 'API Key 验证失败',
|
||||
@@ -490,8 +665,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
|
||||
result = await withTimeout(
|
||||
() => adapter.verifyToken(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||
@@ -524,12 +707,6 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
// New-API family already runs shield-aware probing inside adapters.
|
||||
// Raw fallback probe below does not include challenge-solving and can
|
||||
// misclassify valid Cookie/Session flows as shield-blocked.
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
|
||||
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
@@ -545,7 +722,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
@@ -564,8 +742,9 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
@@ -573,12 +752,19 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
@@ -599,6 +785,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -187,4 +187,114 @@ describe('accounts verify-token shield detection', () => {
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to needsUserId diagnosis when verifyToken hangs', async () => {
|
||||
vi.useFakeTimers();
|
||||
verifyTokenMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id',
|
||||
url: 'https://needs-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const responsePromise = app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'slow-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_100);
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('short-circuits to needsUserId before heavy verification when probe already confirms it', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
tokenType: 'session',
|
||||
userInfo: { username: 'should-not-run' },
|
||||
balance: null,
|
||||
apiToken: null,
|
||||
});
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id Fast',
|
||||
url: 'https://needs-user-id-fast.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-without-user-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns invalidUserId when the provided user id does not match the token', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'user id mismatch' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Wrong User Id',
|
||||
url: 'https://wrong-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-with-wrong-user-id',
|
||||
platformUserId: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
});
|
||||
expect(response.json()).not.toMatchObject({
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,4 +122,69 @@ describe('settings database runtime config api', () => {
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message || '').toContain('SQLite');
|
||||
});
|
||||
|
||||
it('saves and loads ssl setting correctly', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://root:pass@tidb.example.com:4000/db',
|
||||
ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { dialect?: string; ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(true);
|
||||
|
||||
const dbSsl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
|
||||
expect(dbSsl?.value).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
const getBody = getResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(getBody.success).toBe(true);
|
||||
expect(getBody.saved?.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults ssl to false when not provided', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/mydb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('includes ssl in active runtime state', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
|
||||
const body = response.json() as {
|
||||
active?: { ssl?: boolean };
|
||||
};
|
||||
expect(typeof body.active?.ssl).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,16 +50,19 @@ interface DatabaseMigrationBody {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeDatabaseConfig = {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||
const DB_URL_SETTING_KEY = 'db_url';
|
||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
||||
|
||||
function isValidProxyToken(value: string): boolean {
|
||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||
@@ -316,6 +319,7 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
|
||||
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
|
||||
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
|
||||
const rawSsl = parseJsonValue(map.get(DB_SSL_SETTING_KEY));
|
||||
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -324,10 +328,12 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: rawDialect,
|
||||
connectionString: rawConnection,
|
||||
ssl: rawSsl,
|
||||
});
|
||||
return {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -337,19 +343,24 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
|
||||
const activeDialect = runtimeDbDialect;
|
||||
const activeConnection = (config.dbUrl || '').trim();
|
||||
const activeSsl = config.dbSsl;
|
||||
const restartRequired = !!saved && (
|
||||
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
|
||||
saved.dialect !== activeDialect ||
|
||||
saved.connectionString.trim() !== activeConnection ||
|
||||
saved.ssl !== activeSsl
|
||||
);
|
||||
|
||||
return {
|
||||
active: {
|
||||
dialect: activeDialect,
|
||||
connection: maskRuntimeConnection(activeDialect, activeConnection),
|
||||
ssl: activeSsl,
|
||||
},
|
||||
saved: saved
|
||||
? {
|
||||
dialect: saved.dialect,
|
||||
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
|
||||
ssl: saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired,
|
||||
@@ -690,16 +701,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const normalized = normalizeMigrationInput(request.body || {});
|
||||
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
|
||||
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
|
||||
await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl);
|
||||
|
||||
await appendSettingsEvent({
|
||||
type: 'status',
|
||||
title: '数据库运行配置已更新',
|
||||
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
|
||||
message: `已保存运行数据库配置:${normalized.dialect}${normalized.ssl ? ' (SSL)' : ''}(重启后生效)`,
|
||||
});
|
||||
|
||||
const saved: RuntimeDatabaseConfig = {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user