mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3a6794ea | ||
|
|
c673afb97e |
@@ -375,6 +375,7 @@ const ServerForm = ({
|
|||||||
? {
|
? {
|
||||||
url: formData.url,
|
url: formData.url,
|
||||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||||
|
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||||
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -978,6 +979,49 @@ const ServerForm = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold">
|
||||||
|
{t('server.envVars')}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEnvVar}
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{envVars.map((envVar, index) => (
|
||||||
|
<div key={index} className="flex items-center mb-2">
|
||||||
|
<div className="flex items-center space-x-2 flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={envVar.key}
|
||||||
|
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
|
||||||
|
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||||
|
placeholder={t('server.key')}
|
||||||
|
/>
|
||||||
|
<span className="flex items-center">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={envVar.value}
|
||||||
|
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
|
||||||
|
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||||
|
placeholder={t('server.value')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEnvVar(index)}
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"typeorm": "^0.3.26",
|
"typeorm": "^0.3.26",
|
||||||
|
"undici": "^7.16.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -99,6 +99,9 @@ importers:
|
|||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.26
|
specifier: ^0.3.26
|
||||||
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
|
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
|
||||||
|
undici:
|
||||||
|
specifier: ^7.16.0
|
||||||
|
version: 7.16.0
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -4431,6 +4434,10 @@ packages:
|
|||||||
undici-types@7.13.0:
|
undici-types@7.13.0:
|
||||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||||
|
|
||||||
|
undici@7.16.0:
|
||||||
|
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -8946,6 +8953,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.13.0: {}
|
undici-types@7.13.0: {}
|
||||||
|
|
||||||
|
undici@7.16.0: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
|
|||||||
private async loadKeysWithMigration(): Promise<BearerKey[]> {
|
private async loadKeysWithMigration(): Promise<BearerKey[]> {
|
||||||
const settings = await this.loadSettings();
|
const settings = await this.loadSettings();
|
||||||
|
|
||||||
if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) {
|
// Treat an existing array (including an empty array) as already migrated.
|
||||||
|
// Otherwise, when there are no configured keys, we'd rewrite mcp_settings.json
|
||||||
|
// on every request, which also clears the global settings cache.
|
||||||
|
if (Array.isArray(settings.bearerKeys)) {
|
||||||
return settings.bearerKeys;
|
return settings.bearerKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StreamableHTTPClientTransport,
|
StreamableHTTPClientTransport,
|
||||||
StreamableHTTPClientTransportOptions,
|
StreamableHTTPClientTransportOptions,
|
||||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
|
||||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
@@ -134,6 +135,10 @@ export const cleanupAllServers = (): void => {
|
|||||||
// Helper function to create transport based on server configuration
|
// Helper function to create transport based on server configuration
|
||||||
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
||||||
let transport;
|
let transport;
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
...replaceEnvVars(conf.env || {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (conf.type === 'streamable-http') {
|
if (conf.type === 'streamable-http') {
|
||||||
const options: StreamableHTTPClientTransportOptions = {};
|
const options: StreamableHTTPClientTransportOptions = {};
|
||||||
@@ -152,6 +157,8 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
|||||||
console.log(`OAuth provider configured for server: ${name}`);
|
console.log(`OAuth provider configured for server: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||||
|
|
||||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||||
} else if (conf.url) {
|
} else if (conf.url) {
|
||||||
// SSE transport
|
// SSE transport
|
||||||
@@ -174,13 +181,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
|||||||
console.log(`OAuth provider configured for server: ${name}`);
|
console.log(`OAuth provider configured for server: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||||
|
|
||||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||||
} else if (conf.command && conf.args) {
|
} else if (conf.command && conf.args) {
|
||||||
// Stdio transport
|
// Stdio transport
|
||||||
const env: Record<string, string> = {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
...replaceEnvVars(conf.env || {}),
|
|
||||||
};
|
|
||||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||||
|
|
||||||
const systemConfigDao = getSystemConfigDao();
|
const systemConfigDao = getSystemConfigDao();
|
||||||
|
|||||||
167
src/services/proxy.ts
Normal file
167
src/services/proxy.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* HTTP/HTTPS proxy configuration utilities for MCP client transports.
|
||||||
|
*
|
||||||
|
* This module provides utilities to configure HTTP and HTTPS proxies when
|
||||||
|
* connecting to MCP servers. Proxies are configured by providing a custom
|
||||||
|
* fetch implementation that uses Node.js http/https agents with proxy support.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for HTTP/HTTPS proxy settings.
|
||||||
|
*/
|
||||||
|
export interface ProxyConfig {
|
||||||
|
/**
|
||||||
|
* HTTP proxy URL (e.g., 'http://proxy.example.com:8080')
|
||||||
|
* Can include authentication: 'http://user:pass@proxy.example.com:8080'
|
||||||
|
*/
|
||||||
|
httpProxy?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPS proxy URL (e.g., 'https://proxy.example.com:8443')
|
||||||
|
* Can include authentication: 'https://user:pass@proxy.example.com:8443'
|
||||||
|
*/
|
||||||
|
httpsProxy?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated list of hosts that should bypass the proxy
|
||||||
|
* (e.g., 'localhost,127.0.0.1,.example.com')
|
||||||
|
*/
|
||||||
|
noProxy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fetch function that uses the specified proxy configuration.
|
||||||
|
*
|
||||||
|
* This function returns a fetch implementation that routes requests through
|
||||||
|
* the configured HTTP/HTTPS proxies using undici's ProxyAgent.
|
||||||
|
*
|
||||||
|
* Note: This function requires the 'undici' package to be installed.
|
||||||
|
* Install it with: npm install undici
|
||||||
|
*
|
||||||
|
* @param config - Proxy configuration options
|
||||||
|
* @returns A fetch-compatible function configured to use the specified proxies
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function createFetchWithProxy(config: ProxyConfig): FetchLike {
|
||||||
|
// If no proxy is configured, return the default fetch
|
||||||
|
if (!config.httpProxy && !config.httpsProxy) {
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse no_proxy list
|
||||||
|
const noProxyList = parseNoProxy(config.noProxy);
|
||||||
|
|
||||||
|
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const targetUrl = typeof url === 'string' ? new URL(url) : url;
|
||||||
|
|
||||||
|
// Check if host should bypass proxy
|
||||||
|
if (shouldBypassProxy(targetUrl.hostname, noProxyList)) {
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which proxy to use based on protocol
|
||||||
|
const proxyUrl = targetUrl.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
|
||||||
|
|
||||||
|
if (!proxyUrl) {
|
||||||
|
// No proxy configured for this protocol
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use undici for proxy support if available
|
||||||
|
try {
|
||||||
|
// Dynamic import - undici is an optional peer dependency
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const undici = await import('undici' as any);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ProxyAgent = (undici as any).ProxyAgent;
|
||||||
|
const dispatcher = new ProxyAgent(proxyUrl);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
// @ts-expect-error - dispatcher is undici-specific
|
||||||
|
dispatcher,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// undici not available - throw error requiring installation
|
||||||
|
throw new Error(
|
||||||
|
'Proxy support requires the "undici" package. ' +
|
||||||
|
'Install it with: npm install undici\n' +
|
||||||
|
`Original error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a NO_PROXY environment variable value into a list of patterns.
|
||||||
|
*/
|
||||||
|
function parseNoProxy(noProxy?: string): string[] {
|
||||||
|
if (!noProxy) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return noProxy
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a hostname should bypass the proxy based on NO_PROXY patterns.
|
||||||
|
*/
|
||||||
|
function shouldBypassProxy(hostname: string, noProxyList: string[]): boolean {
|
||||||
|
if (noProxyList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostnameLower = hostname.toLowerCase();
|
||||||
|
|
||||||
|
for (const pattern of noProxyList) {
|
||||||
|
const patternLower = pattern.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (hostnameLower === patternLower) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain suffix match (e.g., .example.com matches sub.example.com)
|
||||||
|
if (patternLower.startsWith('.') && hostnameLower.endsWith(patternLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain suffix match without leading dot
|
||||||
|
if (!patternLower.startsWith('.') && hostnameLower.endsWith('.' + patternLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: "*" matches everything
|
||||||
|
if (patternLower === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ProxyConfig from environment variables.
|
||||||
|
*
|
||||||
|
* This function reads standard proxy environment variables:
|
||||||
|
* - HTTP_PROXY, http_proxy
|
||||||
|
* - HTTPS_PROXY, https_proxy
|
||||||
|
* - NO_PROXY, no_proxy
|
||||||
|
*
|
||||||
|
* Lowercase versions take precedence over uppercase versions.
|
||||||
|
*
|
||||||
|
* @returns A ProxyConfig object populated from environment variables
|
||||||
|
*/
|
||||||
|
export function getProxyConfigFromEnv(env: Record<string, string>): ProxyConfig {
|
||||||
|
return {
|
||||||
|
httpProxy: env.http_proxy || env.HTTP_PROXY,
|
||||||
|
httpsProxy: env.https_proxy || env.HTTPS_PROXY,
|
||||||
|
noProxy: env.no_proxy || env.NO_PROXY,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
tests/dao/bearerKeyDao.test.ts
Normal file
97
tests/dao/bearerKeyDao.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { BearerKeyDaoImpl } from '../../src/dao/BearerKeyDao.js';
|
||||||
|
|
||||||
|
const writeSettings = (settingsPath: string, settings: unknown): void => {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BearerKeyDaoImpl migration + settings caching behavior', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
let originalSettingsEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcphub-bearer-keys-'));
|
||||||
|
settingsPath = path.join(tmpDir, 'mcp_settings.json');
|
||||||
|
|
||||||
|
originalSettingsEnv = process.env.MCPHUB_SETTING_PATH;
|
||||||
|
process.env.MCPHUB_SETTING_PATH = settingsPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalSettingsEnv === undefined) {
|
||||||
|
delete process.env.MCPHUB_SETTING_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.MCPHUB_SETTING_PATH = originalSettingsEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite settings when bearerKeys exists as an empty array', async () => {
|
||||||
|
writeSettings(settingsPath, {
|
||||||
|
mcpServers: {},
|
||||||
|
users: [],
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bearerKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||||
|
|
||||||
|
const dao = new BearerKeyDaoImpl();
|
||||||
|
const enabled1 = await dao.findEnabled();
|
||||||
|
const enabled2 = await dao.findEnabled();
|
||||||
|
|
||||||
|
expect(enabled1).toEqual([]);
|
||||||
|
expect(enabled2).toEqual([]);
|
||||||
|
|
||||||
|
// The DAO should NOT persist anything because bearerKeys already exists.
|
||||||
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates legacy bearerAuthKey only once', async () => {
|
||||||
|
writeSettings(settingsPath, {
|
||||||
|
mcpServers: {},
|
||||||
|
users: [],
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'legacy-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// bearerKeys is intentionally missing to trigger migration
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||||
|
|
||||||
|
const dao = new BearerKeyDaoImpl();
|
||||||
|
|
||||||
|
const enabled1 = await dao.findEnabled();
|
||||||
|
expect(enabled1).toHaveLength(1);
|
||||||
|
expect(enabled1[0].token).toBe('legacy-token');
|
||||||
|
expect(enabled1[0].enabled).toBe(true);
|
||||||
|
|
||||||
|
const enabled2 = await dao.findEnabled();
|
||||||
|
expect(enabled2).toHaveLength(1);
|
||||||
|
expect(enabled2[0].token).toBe('legacy-token');
|
||||||
|
|
||||||
|
// One write for the migration, no further writes on subsequent reads.
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user