diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0f4994..bb09c347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Fixed +- **Dashboard Copy deploy string with invalid/placeholder public key (#340):** client config / deploy string / QR now require a valid Ed25519 key (base64 → 32 bytes), reject placeholders, and fall back to the live Go `GET /api/server-key` when `id_ed25519.pub` is missing or bad. Windows installer also sets `PUB_KEY_PATH` in the console NSSM environment. Ships via panel update (re-run `betterdesk.ps1` service setup to refresh NSSM env on Windows). + ### Changed - _(none yet)_ diff --git a/betterdesk.ps1 b/betterdesk.ps1 index 4bea2b15..8ea8d1e4 100644 --- a/betterdesk.ps1 +++ b/betterdesk.ps1 @@ -2089,6 +2089,7 @@ function Setup-Services { "KEYS_PATH=$script:RUSTDESK_PATH", "DATA_DIR=$script:CONSOLE_PATH\data", "DB_PATH=$script:RUSTDESK_PATH\db_v2.sqlite3", + "PUB_KEY_PATH=$script:RUSTDESK_PATH\id_ed25519.pub", "API_KEY_PATH=$script:RUSTDESK_PATH\.api_key", "HBBS_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api", "BETTERDESK_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api", diff --git a/web-nodejs/routes/dashboard.routes.js b/web-nodejs/routes/dashboard.routes.js index d2d2236c..e005f081 100644 --- a/web-nodejs/routes/dashboard.routes.js +++ b/web-nodejs/routes/dashboard.routes.js @@ -32,9 +32,9 @@ router.get('/api/stats', requireAuth, async (req, res) => { // Get server health const hbbsHealth = await serverBackend.getHealth(); - // Get public key info - const publicKey = keyService.getPublicKey(); - + // Get public key info (file or live Go key) + const publicKey = await keyService.resolvePublicKey(); + res.json({ success: true, data: { @@ -148,7 +148,7 @@ router.get('/api/dashboard/client-config', requireAuth, async (req, res) => { try { const queryHost = typeof req.query.host === 'string' ? req.query.host : ''; const endpoints = clientConfigHost.resolveRustDeskEndpoints(req, queryHost); - const clientConfig = keyService.getClientConfig(endpoints); + const clientConfig = await keyService.getClientConfig(endpoints); const qr = await keyService.getServerConfigQR(endpoints); res.json({ diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js index f946fa91..6b6fc947 100644 --- a/web-nodejs/routes/generator.routes.js +++ b/web-nodejs/routes/generator.routes.js @@ -112,8 +112,13 @@ function finalizeBundleBrandingSync(input) { * installation registers on its own and receives a unique device_token * after operator approval (managed enrollment). */ -function finalizeBundleBranding(input) { +async function finalizeBundleBranding(input) { const branding = finalizeBundleBrandingSync(input); + const pubKey = (await keyService.resolvePublicKey()) || ''; + if (branding.server) { + branding.server.public_key = pubKey; + } + branding.server_key = pubKey; // Strip legacy shared tokens from older bundles on save/rebuild. delete branding.enrollment_token; delete branding.has_enrollment_token; @@ -189,14 +194,14 @@ router.get('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async } }); -router.get('/api/generator/defaults', requireAuth, requireAdmin, (req, res) => { +router.get('/api/generator/defaults', requireAuth, requireAdmin, async (req, res) => { res.json({ success: true, data: { server_host: conn.defaultServerHost(), use_https: conn.defaultUseHttps(), api_port: conn.defaultApiPort(), - public_key: keyService.getPublicKey() || '', + public_key: (await keyService.resolvePublicKey()) || '', }, }); }); @@ -231,7 +236,7 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res } const normalized = productType === 'rdclient' ? { ...base, bundle_id: bundleId, server_url: base.panel_url } - : finalizeBundleBranding(base); + : await finalizeBundleBranding(base); if (productType !== 'rdclient') { normalized.bundle_id = bundleId; normalized.product_name = productType === 'agent-client' @@ -271,7 +276,7 @@ router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async if (!valid) { return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors }); } - const normalized = finalizeBundleBranding(base); + const normalized = await finalizeBundleBranding(base); normalized.bundle_id = req.params.bundleId; normalized.product_name = normalizeProductType(existing.product_type) === 'agent-client' ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent') @@ -511,9 +516,9 @@ router.get('/api/d/:publicId/download/:platform/:arch/:format', async (req, res) // Legacy TOML config generator (deprecated, kept for compatibility) // ========================================================================= -router.get('/api/generator/config', requireAuth, (req, res) => { +router.get('/api/generator/config', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); + const publicKey = await keyService.resolvePublicKey(); res.json({ success: true, data: { @@ -528,13 +533,13 @@ router.get('/api/generator/config', requireAuth, (req, res) => { } }); -router.post('/api/generator/generate-config', requireAuth, (req, res) => { +router.post('/api/generator/generate-config', requireAuth, async (req, res) => { try { const { serverHost, serverPort, relayHost, relayPort, clientName } = req.body; if (!serverHost) { return res.status(400).json({ success: false, error: 'Server host is required' }); } - const publicKey = keyService.getPublicKey(); + const publicKey = await keyService.resolvePublicKey(); const lines = []; lines.push(`rendezvous_server = ${serverHost}:${serverPort || 21116}`); if (relayHost) lines.push(`relay_server = ${relayHost}:${relayPort || 21117}`); diff --git a/web-nodejs/routes/keys.routes.js b/web-nodejs/routes/keys.routes.js index 30309bd1..23d22f1d 100644 --- a/web-nodejs/routes/keys.routes.js +++ b/web-nodejs/routes/keys.routes.js @@ -39,17 +39,17 @@ router.get('/keys', requireAuth, (req, res) => { /** * GET /api/keys/public - Get public key */ -router.get('/api/keys/public', requireAuth, (req, res) => { +router.get('/api/keys/public', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); - + const publicKey = await keyService.resolvePublicKey(); + if (!publicKey) { return res.status(404).json({ success: false, error: req.t('keys.not_found') }); } - + res.json({ success: true, data: { @@ -100,17 +100,17 @@ router.get('/api/keys/public/qr', requireAuth, async (req, res) => { /** * GET /api/keys/public/download - Download public key file */ -router.get('/api/keys/public/download', requireAuth, (req, res) => { +router.get('/api/keys/public/download', requireAuth, async (req, res) => { try { - const publicKey = keyService.getPublicKey(); - + const publicKey = await keyService.resolvePublicKey(); + if (!publicKey) { return res.status(404).json({ success: false, error: req.t('keys.not_found') }); } - + res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Disposition', 'attachment; filename="id_ed25519.pub"'); res.send(publicKey); diff --git a/web-nodejs/routes/registration.routes.js b/web-nodejs/routes/registration.routes.js index a19e68c9..3a76aae1 100644 --- a/web-nodejs/routes/registration.routes.js +++ b/web-nodejs/routes/registration.routes.js @@ -24,7 +24,6 @@ const express = require('express'); const router = express.Router(); const crypto = require('crypto'); -const fs = require('fs'); const db = require('../services/database'); const config = require('../config/config'); const { requirePermission } = require('../middleware/auth'); @@ -57,13 +56,12 @@ function getClientIp(req) { } /** - * Read the server public key from disk (base64). + * Read the server public key (validated file, then live Go key). */ -function getServerPublicKey() { +async function getServerPublicKey() { try { - if (fs.existsSync(config.pubKeyPath)) { - return fs.readFileSync(config.pubKeyPath, 'utf8').trim(); - } + const keyService = require('../services/keyService'); + return (await keyService.resolvePublicKey()) || ''; } catch (_) { /* ignore */ } return ''; } @@ -78,14 +76,14 @@ function generateDeviceAccessToken() { /** * Build the server config payload returned to devices upon approval. */ -function buildServerConfig() { +async function buildServerConfig() { const protocol = config.httpsEnabled ? 'https' : 'http'; const consoleUrl = `${protocol}://0.0.0.0:${config.port}`; return { console_url: consoleUrl, server_address: `0.0.0.0:21116`, - server_key: getServerPublicKey(), + server_key: await getServerPublicKey(), access_token: generateDeviceAccessToken(), }; } @@ -283,7 +281,7 @@ router.put('/api/registrations/:id/approve', requirePermission('enrollment.appro } // Build server config — use actual server address from the request - const serverConfig = buildServerConfig(); + const serverConfig = await buildServerConfig(); // Replace 0.0.0.0 with the actual hostname / IP the admin is accessing const actualHost = req.headers.host?.split(':')[0] || req.hostname || 'localhost'; diff --git a/web-nodejs/routes/remote.routes.js b/web-nodejs/routes/remote.routes.js index c020a91b..7356473f 100644 --- a/web-nodejs/routes/remote.routes.js +++ b/web-nodejs/routes/remote.routes.js @@ -78,13 +78,13 @@ function getRemoteRelay() { try { return require('../services/remoteRelay'); } catch { return null; } } -// Read server public key on each viewer render (Go may write id_ed25519.pub after -// console start; avoid caching empty/stale key across the process lifetime). -function getServerPubKey() { +// Resolve server public key on each viewer render (file may be stale/wrong; +// fall back to live Go /api/server-key — issue #340). +async function resolveServerPubKey() { try { - return keyService.getPublicKey() || ''; + return (await keyService.resolvePublicKey()) || ''; } catch (err) { - console.warn('Warning: Could not read server public key:', err.message); + console.warn('Warning: Could not resolve server public key:', err.message); return ''; } } @@ -233,7 +233,7 @@ router.get('/remote/:deviceId', rdClientPageLimiter, requireRemoteAccess, async activePage: 'remote', deviceId: deviceId, device: device || { id: deviceId, hostname: '', platform: '', note: '' }, - serverPubKey: getServerPubKey(), + serverPubKey: await resolveServerPubKey(), capabilities, guestToken: req.guestToken || getGuestTokenFromQuery(req) || '', layout: 'viewer' diff --git a/web-nodejs/routes/rustdesk-api.routes.js b/web-nodejs/routes/rustdesk-api.routes.js index a3a26443..a1dfa49f 100644 --- a/web-nodejs/routes/rustdesk-api.routes.js +++ b/web-nodejs/routes/rustdesk-api.routes.js @@ -1596,19 +1596,11 @@ function cleanupTfaSessions(sessions) { * This key is used by clients to verify peer identity (signed_id_pk). * Public key is inherently safe to expose — no auth required. */ -router.get('/api/server-key', (req, res) => { +router.get('/api/server-key', async (req, res) => { try { - if (!fs.existsSync(config.pubKeyPath)) { - return res.json({ key: '' }); - } - const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); - // Validate: should decode to 32 bytes (Ed25519 public key) - const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== 32) { - console.warn('[API:SERVER-KEY] Invalid RS public key length:', decoded.length); - return res.json({ key: '' }); - } - return res.json({ key }); + const keyService = require('../services/keyService'); + const key = await keyService.resolvePublicKey(); + return res.json({ key: key || '' }); } catch (err) { console.warn('[API:SERVER-KEY] Error reading public key:', err.message); return res.json({ key: '' }); @@ -1619,12 +1611,13 @@ router.get('/api/server-key', (req, res) => { * GET /api/server-key/fingerprint * Returns SHA-256 fingerprint of RS public key for out-of-band verification. */ -router.get('/api/server-key/fingerprint', (req, res) => { +router.get('/api/server-key/fingerprint', async (req, res) => { try { - if (!fs.existsSync(config.pubKeyPath)) { + const keyService = require('../services/keyService'); + const key = await keyService.resolvePublicKey(); + if (!key) { return res.json({ fingerprint: '', algorithm: 'SHA-256' }); } - const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); const hash = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex'); return res.json({ fingerprint: hash.match(/.{2}/g).join(':').toUpperCase(), diff --git a/web-nodejs/services/keyService.js b/web-nodejs/services/keyService.js index 0f4e98c5..157008a2 100644 --- a/web-nodejs/services/keyService.js +++ b/web-nodejs/services/keyService.js @@ -1,6 +1,6 @@ /** * BetterDesk Console - Key Service - * Reads public key and API key from filesystem + * Reads public key and API key from filesystem; resolves live Go key as fallback. */ const fs = require('fs'); @@ -8,21 +8,114 @@ const QRCode = require('qrcode'); const config = require('../config/config'); const conn = require('./agentBundleConnection'); +const ED25519_PUBLIC_KEY_BYTES = 32; +const GO_KEY_CACHE_TTL_MS = 30_000; + +/** @type {{ key: string|null, at: number }} */ +let goKeyCache = { key: null, at: 0 }; + /** - * Read public key from file + * True when value is a valid RustDesk server public key (base64 → 32 bytes). + * Rejects empty values, unresolved env tokens, and obvious placeholders. + * @param {unknown} value + * @returns {boolean} + */ +function isValidRustDeskPublicKey(value) { + if (typeof value !== 'string') return false; + const key = value.trim(); + if (!key) return false; + if (/__[^_\s]+__/.test(key)) return false; + if (/placeholder/i.test(key)) return false; + if (/^YOUR[_-]?PUBLIC[_-]?KEY$/i.test(key)) return false; + if (/\s/.test(key)) return false; + + try { + const decoded = Buffer.from(key, 'base64'); + // Reject non-canonical base64 (padding / alphabet mismatch) + if (decoded.length !== ED25519_PUBLIC_KEY_BYTES) return false; + const reencoded = decoded.toString('base64'); + // Allow missing padding on input by comparing without '=' + if (reencoded.replace(/=+$/, '') !== key.replace(/=+$/, '')) return false; + return true; + } catch { + return false; + } +} + +/** + * Read and validate public key from the configured pubkey file. + * Invalid / placeholder content is treated as missing (never returned to clients). + * @returns {string|null} */ function getPublicKey() { try { - if (fs.existsSync(config.pubKeyPath)) { - return fs.readFileSync(config.pubKeyPath, 'utf8').trim(); + if (!fs.existsSync(config.pubKeyPath)) { + return null; } - return null; + const raw = fs.readFileSync(config.pubKeyPath, 'utf8').trim(); + if (!raw) return null; + if (!isValidRustDeskPublicKey(raw)) { + console.warn( + `Public key at ${config.pubKeyPath} is not a valid Ed25519 key ` + + `(length=${raw.length}); ignoring for client deploy/config.` + ); + return null; + } + return raw; } catch (err) { console.warn('Could not read public key:', err.message); return null; } } +/** + * Fetch the live rendezvous public key from the Go server. + * @returns {Promise} + */ +async function fetchPublicKeyFromGo() { + try { + const betterdeskApi = require('./betterdeskApi'); + const resp = await betterdeskApi.apiClient.get('/server-key', { timeout: 5000 }); + const key = typeof resp.data?.key === 'string' ? resp.data.key.trim() : ''; + if (isValidRustDeskPublicKey(key)) { + return key; + } + return null; + } catch (err) { + console.warn('Could not fetch public key from Go /api/server-key:', err.message); + return null; + } +} + +/** + * Resolve the server public key: validated file first, then live Go API (cached). + * @returns {Promise} + */ +async function resolvePublicKey() { + // Prefer module.exports so tests can spy on getPublicKey. + const fromFile = module.exports.getPublicKey(); + if (fromFile) return fromFile; + + const now = Date.now(); + if (goKeyCache.key && (now - goKeyCache.at) < GO_KEY_CACHE_TTL_MS) { + return goKeyCache.key; + } + + const fromGo = await fetchPublicKeyFromGo(); + if (fromGo) { + goKeyCache = { key: fromGo, at: now }; + return fromGo; + } + + goKeyCache = { key: null, at: now }; + return null; +} + +/** Test helper — clears Go key cache. */ +function _resetGoKeyCacheForTests() { + goKeyCache = { key: null, at: 0 }; +} + /** * Get API key (masked for display) */ @@ -65,10 +158,12 @@ function apiUrlForHost(host, useHttps) { /** * RustDesk client config JSON payload: { host, relay, api, key } * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost - * @param {{ useHttps?: boolean }} [options] + * @param {{ useHttps?: boolean, publicKey?: string }} [options] */ function buildRustDeskConfigPayload(endpointsOrHost, options = {}) { - const pubKey = getPublicKey() || ''; + const pubKey = options.publicKey !== undefined + ? (isValidRustDeskPublicKey(options.publicKey) ? String(options.publicKey).trim() : '') + : (module.exports.getPublicKey() || ''); const useHttps = options.useHttps ?? conn.defaultUseHttps(); if (typeof endpointsOrHost === 'string') { @@ -92,6 +187,18 @@ function buildRustDeskConfigPayload(endpointsOrHost, options = {}) { }; } +/** + * Like buildRustDeskConfigPayload but resolves the live public key (file → Go). + * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost + * @param {{ useHttps?: boolean, publicKey?: string }} [options] + */ +async function buildRustDeskConfigPayloadAsync(endpointsOrHost, options = {}) { + const publicKey = options.publicKey !== undefined + ? options.publicKey + : (await resolvePublicKey()) || ''; + return buildRustDeskConfigPayload(endpointsOrHost, { ...options, publicKey }); +} + /** * QR / deep-link format: rustdesk://config/ */ @@ -113,17 +220,17 @@ function encodeRustDeskCliConfigString(payload) { /** * Generate QR code containing the RustDesk configuration URI. - * Format: rustdesk://config/ + * Format: rustdesk://config/ * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost */ async function getServerConfigQR(endpointsOrHost) { - const pubKey = getPublicKey(); + const pubKey = await resolvePublicKey(); if (!pubKey) { return null; } try { - const configPayload = buildRustDeskConfigPayload(endpointsOrHost); + const configPayload = await buildRustDeskConfigPayloadAsync(endpointsOrHost, { publicKey: pubKey }); const configUri = encodeRustDeskConfigUri(configPayload); const qrDataUrl = await QRCode.toDataURL(configUri, { @@ -145,10 +252,11 @@ async function getServerConfigQR(endpointsOrHost) { /** * Build the RustDesk client fields operators need to enter manually. + * Uses validated file key, then live Go /api/server-key as fallback (#340). * @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost */ -function getClientConfig(endpointsOrHost) { - const payload = buildRustDeskConfigPayload(endpointsOrHost); +async function getClientConfig(endpointsOrHost) { + const payload = await buildRustDeskConfigPayloadAsync(endpointsOrHost); const publicKey = payload.key; return { @@ -166,7 +274,7 @@ function getClientConfig(endpointsOrHost) { * Generate QR code for public key (legacy — raw key text) */ async function getPublicKeyQR() { - const pubKey = getPublicKey(); + const pubKey = await resolvePublicKey(); if (!pubKey) { return null; } @@ -192,9 +300,9 @@ async function getPublicKeyQR() { /** * Get server configuration info */ -function getServerConfig() { +async function getServerConfig() { return { - publicKey: getPublicKey(), + publicKey: await resolvePublicKey(), apiKeyMasked: getApiKey(true), hbbsApiUrl: config.hbbsApiUrl, dbPath: config.dbPath, @@ -204,15 +312,19 @@ function getServerConfig() { } module.exports = { + isValidRustDeskPublicKey, getPublicKey, + resolvePublicKey, getApiKey, getPublicKeyQR, getServerConfigQR, getClientConfig, getServerConfig, buildRustDeskConfigPayload, + buildRustDeskConfigPayloadAsync, encodeRustDeskConfigUri, encodeRustDeskCliConfigString, normalizeHostInput, apiUrlForHost, + _resetGoKeyCacheForTests, }; diff --git a/web-nodejs/tests/keyService.test.js b/web-nodejs/tests/keyService.test.js index 7d0df04c..4e483e2d 100644 --- a/web-nodejs/tests/keyService.test.js +++ b/web-nodejs/tests/keyService.test.js @@ -27,7 +27,9 @@ describe('keyService RustDesk config encoding', () => { }); it('buildRustDeskConfigPayload normalizes host input', () => { - const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path'); + const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path', { + publicKey: '', + }); expect(payload.host).toBe('desk.example.com'); expect(payload.relay).toBe('desk.example.com'); }); @@ -37,9 +39,84 @@ describe('keyService RustDesk config encoding', () => { host: 'remote.example.com', relay: 'relay.example.com', api: 'https://api.example.com', - }); + }, { publicKey: '' }); expect(payload.host).toBe('remote.example.com'); expect(payload.relay).toBe('relay.example.com'); expect(payload.api).toBe('https://api.example.com'); }); }); + +describe('keyService public key validation (#340)', () => { + // 32 zero bytes → canonical base64 (44 chars with padding) + const validKey = Buffer.alloc(32, 0).toString('base64'); + + afterEach(() => { + keyService._resetGoKeyCacheForTests(); + jest.restoreAllMocks(); + }); + + it('isValidRustDeskPublicKey accepts canonical 32-byte base64', () => { + expect(keyService.isValidRustDeskPublicKey(validKey)).toBe(true); + }); + + it('isValidRustDeskPublicKey rejects placeholders and junk', () => { + expect(keyService.isValidRustDeskPublicKey('')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('v1.4.9_public_key_placeholder...')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('YOUR_PUBLIC_KEY')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('__PUB_KEY_PATH__')).toBe(false); + expect(keyService.isValidRustDeskPublicKey('not-base64!!!')).toBe(false); + expect(keyService.isValidRustDeskPublicKey(Buffer.alloc(16).toString('base64'))).toBe(false); + }); + + it('getClientConfig embeds valid public key in deploy string', async () => { + const payload = keyService.buildRustDeskConfigPayload('desk.example.com', { publicKey: validKey }); + const deploy = keyService.encodeRustDeskCliConfigString(payload); + const json = Buffer.from(deploy.split('').reverse().join(''), 'base64').toString('utf8'); + const decoded = JSON.parse(json); + expect(decoded.key).toBe(validKey); + expect(decoded.host).toBe('desk.example.com'); + expect(payload.key).toBe(validKey); + }); + + it('buildRustDeskConfigPayload drops invalid publicKey override', () => { + const payload = keyService.buildRustDeskConfigPayload('desk.example.com', { + publicKey: 'v1.4.9_public_key_placeholder...', + }); + expect(payload.key).toBe(''); + }); + + it('getClientConfig with invalid file key and Go fallback fills deploy string', async () => { + jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null); + const betterdeskApi = require('../services/betterdeskApi'); + jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({ data: { key: validKey } }); + + const config = await keyService.getClientConfig({ + host: '203.0.113.10', + relay: '203.0.113.10', + api: 'http://203.0.113.10:21114', + }); + + expect(config.has_public_key).toBe(true); + expect(config.public_key).toBe(validKey); + expect(config.deploy_config_string).toBeTruthy(); + + const json = Buffer.from( + config.deploy_config_string.split('').reverse().join(''), + 'base64' + ).toString('utf8'); + expect(JSON.parse(json).key).toBe(validKey); + }); + + it('getClientConfig leaves deploy string empty when no valid key exists', async () => { + jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null); + const betterdeskApi = require('../services/betterdeskApi'); + jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({ + data: { key: 'v1.4.9_public_key_placeholder...' }, + }); + + const config = await keyService.getClientConfig('desk.example.com'); + expect(config.has_public_key).toBe(false); + expect(config.public_key).toBe(''); + expect(config.deploy_config_string).toBe(''); + }); +});