From ba96085fc45a8d0b39babd59bd0f6d9141c63a36 Mon Sep 17 00:00:00 2001 From: Sebastian Tiedtke Date: Mon, 24 Jul 2023 11:01:26 -0400 Subject: [PATCH 1/2] Custom app config relies on base domains now --- package.json | 5 +- src/extension/api/client.ts | 6 ++- src/extension/panels/panel.ts | 3 +- src/extension/services/runme.ts | 11 ++-- src/utils/configuration.ts | 40 +++++++++++++-- tests/extension/configuration.test.ts | 73 +++++++++++++++++++++------ tests/extension/panels/panel.test.ts | 65 ++++++++++++++++++++++-- 7 files changed, 172 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 7644fa439..f0bedf1c8 100644 --- a/package.json +++ b/package.json @@ -474,10 +474,11 @@ "default": false, "markdownDescription": "Whether to use full path to integrated runme executable when running CLI commands. This is mostly useful for development purposes." }, - "runme.app.apiUrl": { + "runme.app.baseDomain": { "type": "string", "default": "", - "markdownDescription": "App API URL" + "scope": "window", + "markdownDescription": "Base domain to be use for Runme app" }, "runme.app.enableShare": { "type": "boolean", diff --git a/src/extension/api/client.ts b/src/extension/api/client.ts index 331f69303..a6c83a29d 100644 --- a/src/extension/api/client.ts +++ b/src/extension/api/client.ts @@ -1,8 +1,9 @@ import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' import fetch from 'cross-fetch' import { setContext } from '@apollo/client/link/context' +import { Uri } from 'vscode' -import { getRunmeApiUrl } from '../../utils/configuration' +import { getRunmeAppUrl } from '../../utils/configuration' export function InitializeClient({ uri, @@ -19,7 +20,8 @@ export function InitializeClient({ }, } }) - const link = new HttpLink({ fetch, uri: uri || `${getRunmeApiUrl()}/graphql` }) + const appApiUrl = Uri.joinPath(Uri.parse(getRunmeAppUrl(['api']), true), '/graphql').toString() + const link = new HttpLink({ fetch, uri: uri || appApiUrl }) const client = new ApolloClient({ cache: new InMemoryCache(), credentials: 'include', diff --git a/src/extension/panels/panel.ts b/src/extension/panels/panel.ts index ef876157a..4af3a78e6 100644 --- a/src/extension/panels/panel.ts +++ b/src/extension/panels/panel.ts @@ -6,6 +6,7 @@ import { Observable, Subscription } from 'rxjs' import { fetchStaticHtml, getAuthSession } from '../utils' import { IAppToken, RunmeService } from '../services/runme' import { SyncSchemaBus } from '../../types' +import { getRunmeAppUrl } from '../../utils/configuration' export type DefaultUx = 'panels' export interface InitPayload { @@ -16,7 +17,7 @@ export interface InitPayload { } class PanelBase extends TelemetryViewProvider implements Disposable { - protected readonly appUrl: string = 'http://localhost:4001' + protected readonly appUrl: string = getRunmeAppUrl(['app']) protected readonly defaultUx: DefaultUx = 'panels' constructor(protected readonly context: ExtensionContext) { diff --git a/src/extension/services/runme.ts b/src/extension/services/runme.ts index 5fd08b6a2..7493f60c3 100644 --- a/src/extension/services/runme.ts +++ b/src/extension/services/runme.ts @@ -1,6 +1,7 @@ import fetch from 'cross-fetch' +import { Uri } from 'vscode' -import { getRunmeApiUrl } from '../../utils/configuration' +import { getRunmeAppUrl } from '../../utils/configuration' export interface IUserToken { token: string @@ -12,13 +13,16 @@ export interface IAppToken { export class RunmeService { protected githubAccessToken: string + private readonly apiBase = Uri.parse(getRunmeAppUrl(['api']), true) + constructor({ githubAccessToken }: { githubAccessToken: string }) { this.githubAccessToken = githubAccessToken } async getUserToken(): Promise { + const userAuthEndpoint = Uri.joinPath(this.apiBase, '/auth/vscode').toString() let response try { - response = await fetch(`${getRunmeApiUrl()}/auth/vscode`, { + response = await fetch(userAuthEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -40,9 +44,10 @@ export class RunmeService { return response.json() } async getAppToken(userToken: IUserToken): Promise { + const appAuthEndpoint = Uri.joinPath(this.apiBase, '/auth/user/app').toString() let response try { - response = await fetch(`${getRunmeApiUrl()}/auth/user/app`, { + response = await fetch(appAuthEndpoint, { method: 'POST', headers: { Authorization: `Bearer ${userToken.token}`, diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index ffb51e0f9..bf27ed412 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -19,7 +19,12 @@ const APP_SECTION_NAME = 'runme.app' export const OpenViewInEditorAction = z.enum(['split', 'toggle']) export const DEFAULT_TLS_DIR = path.join(os.tmpdir(), 'runme', uuidv4(), 'tls') const DEFAULT_WORKSPACE_FILE_ORDER = ['.env.local', '.env'] -const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev/graphql' +const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev' +const DEFAULT_RUNME_BASE_DOMAIN = 'runme.dev' +const APP_LOOPBACK_MAPPING = new Map([ + ['api.', ':4000'], + ['app.', ':4001'], +]) type NotebookTerminalValue = keyof typeof configurationSchema.notebookTerminal @@ -54,6 +59,7 @@ const configurationSchema = { }, app: { apiUrl: z.string().default(DEFAULT_RUNME_APP_API_URL), + baseDomain: z.string().default(DEFAULT_RUNME_BASE_DOMAIN), enableShare: z.boolean().default(false), }, } @@ -252,8 +258,34 @@ const getCLIUseIntegratedRunme = (): boolean => { return getCLIConfigurationValue('useIntegratedRunme', false) } -const getRunmeApiUrl = (): string => { - return getCloudConfigurationValue('apiUrl', DEFAULT_RUNME_APP_API_URL) +const getRunmeAppUrl = (subdomain: string[]): string => { + const base = getRunmeBaseDomain() + const isLoopback = ['127.0.0.1', 'localhost'] + .map((host) => base.includes(host)) + .reduce((p, c) => p || c) + const scheme = isLoopback ? 'http' : 'https' + + let sub = subdomain.join('.') + if (sub.length > 0) { + sub = `${sub}.` + } + + let port = '' + if (isLoopback && sub.length > 0) { + port = APP_LOOPBACK_MAPPING.get(sub) ?? '' + sub = '' + } + + const url = Uri.parse(`${scheme}://${sub}${base}${port}`, true) + return url.toString() +} + +const getRunmeBaseDomain = (): string => { + const baseDomain = getCloudConfigurationValue('baseDomain', DEFAULT_RUNME_BASE_DOMAIN) + if (baseDomain.length === 0) { + return DEFAULT_RUNME_BASE_DOMAIN + } + return baseDomain } const isRunmeApiEnabled = (): boolean => { @@ -279,6 +311,6 @@ export { getEnvWorkspaceFileOrder, getEnvLoadWorkspaceFiles, getCLIUseIntegratedRunme, - getRunmeApiUrl, + getRunmeAppUrl, isRunmeApiEnabled, } diff --git a/tests/extension/configuration.test.ts b/tests/extension/configuration.test.ts index f1b1ec936..3e6a519f9 100644 --- a/tests/extension/configuration.test.ts +++ b/tests/extension/configuration.test.ts @@ -4,6 +4,7 @@ import { suite, test, expect, vi, beforeEach, afterEach } from 'vitest' import { Uri, workspace } from 'vscode' import { + getRunmeAppUrl, getPortNumber, enableServerLogs, getBinaryPath, @@ -29,11 +30,13 @@ const SETTINGS_MOCK: binaryPath: string | undefined enableLogger: string | boolean | undefined tlsDir: string | undefined + baseDomain: string | undefined } = { port: undefined, binaryPath: undefined, enableLogger: undefined, - tlsDir: undefined + tlsDir: undefined, + baseDomain: undefined, } beforeEach(() => { @@ -82,7 +85,7 @@ function platformPathMocks(platform: path.PlatformPath) { afterEach(() => { Object.keys(SETTINGS_MOCK).forEach(key => { - SETTINGS_MOCK[key] = undefined + SETTINGS_MOCK[key] = undefined }) }) @@ -97,34 +100,34 @@ suite('Configuration', () => { expect(fontSize).toBeUndefined() }) - test('Should default to a valid port number', () => { + test('should default to a valid port number', () => { const portNumber = getPortNumber() expect(portNumber).toStrictEqual(7863) }) - test('Should use a valid specified port number', () => { + test('should use a valid specified port number', () => { const portNumber = getPortNumber() expect(portNumber).toStrictEqual(SERVER_PORT) }) - test('Should disable server logs with an invalid value', () => { + test('should disable server logs with an invalid value', () => { SETTINGS_MOCK.enableLogger = undefined const path = enableServerLogs() expect(path).toBeFalsy() }) - test('Should disable server logs with an invalid string', () => { + test('should disable server logs with an invalid string', () => { SETTINGS_MOCK.enableLogger = 'true' const path = enableServerLogs() expect(path).toBeFalsy() }) - test('Should get default TLS dir by default', () => { + test('should get default TLS dir by default', () => { SETTINGS_MOCK.tlsDir = undefined expect(getTLSDir()).toBe(DEFAULT_TLS_DIR) }) - test('Should get set TLS dir if set', () => { + test('should get set TLS dir if set', () => { SETTINGS_MOCK.tlsDir = '/tmp/runme/tls' expect(getTLSDir()).toBe('/tmp/runme/tls') }) @@ -160,12 +163,12 @@ suite('Configuration', () => { platformPathMocks(path.posix) }) - test('Should default to a valid binaryPath', () => { + test('should default to a valid binaryPath', () => { const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'linux') expect(binary.fsPath).toStrictEqual('/Users/user/.vscode/extension/stateful.runme/bin/runme') }) - test('Should default to a valid relative binaryPath when specified', () => { + test('should default to a valid relative binaryPath when specified', () => { SETTINGS_MOCK.binaryPath = 'newBin' // @ts-expect-error workspace.workspaceFolders = [{ uri: Uri.file('/Users/user/Projects/project') }] @@ -173,13 +176,13 @@ suite('Configuration', () => { expect(binary.fsPath).toStrictEqual('/Users/user/Projects/project/newBin') }) - test('Should default to a valid absolute binaryPath when specified', () => { + test('should default to a valid absolute binaryPath when specified', () => { SETTINGS_MOCK.binaryPath = '/opt/homebrew/bin/runme' const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'linux') expect(binary.fsPath).toStrictEqual('/opt/homebrew/bin/runme') }) - test('Should use runme for non-windows platforms', () => { + test('should use runme for non-windows platforms', () => { SETTINGS_MOCK.binaryPath = '/opt/homebrew/bin/runme' const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'darwin') expect(binary.fsPath).toStrictEqual('/opt/homebrew/bin/runme') @@ -191,21 +194,21 @@ suite('Configuration', () => { platformPathMocks(path.win32) }) - test('Should default to a valid binaryPath exe on windows', () => { + test('should default to a valid binaryPath exe on windows', () => { const binary = getBinaryPath(Uri.file(FAKE_WIN_EXT_PATH), 'win') expect(binary.fsPath).toStrictEqual( 'c:\\Users\\.vscode\\extensions\\stateful.runme\\bin\\runme.exe' ) }) - test('Should use runme.exe for windows platforms with absolute path', () => { + test('should use runme.exe for windows platforms with absolute path', () => { SETTINGS_MOCK.binaryPath = 'C:\\custom\\path\\to\\bin\\runme.exe' const binary = getBinaryPath(Uri.file(FAKE_WIN_EXT_PATH), 'win32') expect(binary.fsPath).toStrictEqual('c:\\custom\\path\\to\\bin\\runme.exe') }) - test('Should use runme.exe for windows platforms with relative path', () => { + test('should use runme.exe for windows platforms with relative path', () => { SETTINGS_MOCK.binaryPath = 'newBin.exe' // @ts-expect-error workspace.workspaceFolders = [{ uri: Uri.file('c:\\Users\\Projects\\project') }] @@ -213,4 +216,44 @@ suite('Configuration', () => { expect(binary.fsPath).toStrictEqual('c:\\Users\\Projects\\project\\newBin.exe') }) }) + + suite('app domain resolution', () => { + test('should return URL for api with subdomain', () => { + const url = getRunmeAppUrl(['api']) + expect(url).toStrictEqual('https://api.runme.dev/') + }) + + test('should return URL for api with deep subdomain', () => { + const url = getRunmeAppUrl(['l4', 'l3', 'api']) + expect(url).toStrictEqual('https://l4.l3.api.runme.dev/') + }) + + test('should return URL without subdomain', () => { + const url = getRunmeAppUrl([]) + expect(url).toStrictEqual('https://runme.dev/') + }) + + test('should return URL without subdomain', () => { + const url = getRunmeAppUrl([]) + expect(url).toStrictEqual('https://runme.dev/') + }) + + test('should allow api URL with http for 127.0.0.1', async () => { + SETTINGS_MOCK.baseDomain = '127.0.0.1' + const url = getRunmeAppUrl(['api']) + expect(url).toStrictEqual('http://127.0.0.1:4000/') + }) + + test('should allow app URL with http for localhost', async () => { + SETTINGS_MOCK.baseDomain = 'localhost' + const url = getRunmeAppUrl(['app']) + expect(url).toStrictEqual('http://localhost:4001/') + }) + + test('should allow app URL with http for localhost without subdomain', async () => { + SETTINGS_MOCK.baseDomain = 'localhost' + const url = getRunmeAppUrl([]) + expect(url).toStrictEqual('http://localhost/') + }) + }) }) diff --git a/tests/extension/panels/panel.test.ts b/tests/extension/panels/panel.test.ts index d421f483e..a9ea48ef0 100644 --- a/tests/extension/panels/panel.test.ts +++ b/tests/extension/panels/panel.test.ts @@ -1,4 +1,4 @@ -import { suite, test, expect, vi } from 'vitest' +import { suite, test, expect, vi, afterEach, beforeEach } from 'vitest' import { Uri, type ExtensionContext, type WebviewView } from 'vscode' import Panel from '../../../src/extension/panels/panel' @@ -9,6 +9,37 @@ vi.mock('vscode-telemetry') vi.mock('../../../src/extension/grpc/client', () => ({})) vi.mock('../../../src/extension/runner', () => ({})) +const SETTINGS_MOCK: + { + baseDomain: string | undefined + } = { + baseDomain: undefined, +} + +beforeEach(() => { + vi.mock('vscode', async () => { + const mocked = await import('../../../__mocks__/vscode') + + return ({ + ...mocked, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: (configurationName) => { + return SETTINGS_MOCK[configurationName] + } + }), + }, + Uri: mocked.Uri, + }) + }) +}) + +afterEach(() => { + Object.keys(SETTINGS_MOCK).forEach(key => { + SETTINGS_MOCK[key] = undefined + }) +}) + vi.mock('../../../src/extension/utils', () => { return { fetchStaticHtml: vi.fn().mockResolvedValue({ @@ -38,7 +69,7 @@ suite('Panel', () => { defaultUx: 'panels', }) - expect(hydrated).toContain('') + expect(hydrated).toContain('') expect(hydrated).toContain( '{"appToken":"a.b.c","ide":"code","panelId":"main","defaultUx":"panels"}' ) @@ -50,7 +81,7 @@ suite('Panel', () => { await p.resolveWebviewTelemetryView(view) - expect(view.webview.html).toContain('') + expect(view.webview.html).toContain('') expect(view.webview.html).toContain( '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}' ) @@ -62,7 +93,33 @@ suite('Panel', () => { await p.resolveWebviewTelemetryView(view) - expect(view.webview.html).toContain('') + expect(view.webview.html).toContain('') + expect(view.webview.html).toContain( + '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}' + ) + }) + + test('resovles authed localhost', async () => { + SETTINGS_MOCK.baseDomain = 'localhost' + const p = new Panel(contextMock, 'testing') + p.getAppToken = vi.fn().mockResolvedValue({ token: 'webview.auth.token' }) + + await p.resolveWebviewTelemetryView(view) + + expect(view.webview.html).toContain('') + expect(view.webview.html).toContain( + '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}' + ) + }) + + test('resovles unauthed localhost', async () => { + SETTINGS_MOCK.baseDomain = 'localhost' + const p = new Panel(contextMock, 'testing') + p.getAppToken = vi.fn().mockResolvedValue(null) + + await p.resolveWebviewTelemetryView(view) + + expect(view.webview.html).toContain('') expect(view.webview.html).toContain( '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}' ) From 40b4a28dff31d69792233e3ad358ae2b989b4164 Mon Sep 17 00:00:00 2001 From: Sebastian Tiedtke Date: Mon, 24 Jul 2023 11:29:02 -0400 Subject: [PATCH 2/2] Make it a constant --- src/utils/configuration.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index bf27ed412..d24dae6fd 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -21,6 +21,7 @@ export const DEFAULT_TLS_DIR = path.join(os.tmpdir(), 'runme', uuidv4(), 'tls') const DEFAULT_WORKSPACE_FILE_ORDER = ['.env.local', '.env'] const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev' const DEFAULT_RUNME_BASE_DOMAIN = 'runme.dev' +const APP_LOOPBACKS = ['127.0.0.1', 'localhost'] const APP_LOOPBACK_MAPPING = new Map([ ['api.', ':4000'], ['app.', ':4001'], @@ -260,9 +261,7 @@ const getCLIUseIntegratedRunme = (): boolean => { const getRunmeAppUrl = (subdomain: string[]): string => { const base = getRunmeBaseDomain() - const isLoopback = ['127.0.0.1', 'localhost'] - .map((host) => base.includes(host)) - .reduce((p, c) => p || c) + const isLoopback = APP_LOOPBACKS.map((host) => base.includes(host)).reduce((p, c) => p || c) const scheme = isLoopback ? 'http' : 'https' let sub = subdomain.join('.')