Skip to content

Commit 71aa6ab

Browse files
authored
More tests for parsing/validating Jupyter Notebook/Lab Urls (#14340)
* Wip * Updates * More mandatory tests * More tests * More tests * Revert * Support for certs * oops * misc changes * support for https * Misc * Fixes to extraction of Url from jupyter CLI output * Further fixes * Revert
1 parent e3cbadc commit 71aa6ab

File tree

7 files changed

+187
-84
lines changed

7 files changed

+187
-84
lines changed

.github/workflows/build-test.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ jobs:
307307
tags: '^[^@]+$|@mandatory'
308308
os: ubuntu-latest
309309
ipywidgetsVersion: ''
310+
- jupyterConnection: remote
311+
python: python
312+
pythonVersion: '3.10'
313+
packageVersion: 'prerelease'
314+
tags: '^[^@]+$|@mandatory'
315+
os: ubuntu-latest
316+
ipywidgetsVersion: ''
310317
- jupyterConnection: web
311318
python: python
312319
pythonVersion: '3.10'

build/venv-test-ipywidgets8-requirements.txt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
pytest < 6.0.0; python_version > '2.7' # Tests do not support pytest 6 yet.
2-
# Python 2.7 compatibility (pytest)
3-
pytest==7.3.1; python_version == '2.7'
41
# Requirements needed to run install_debugpy.py
52
packaging
63
# List of requirements for ipython tests
@@ -21,7 +18,6 @@ py4j
2118
bqplot
2219
K3D
2320
ipyleaflet
24-
jinja2==3.1.2 # https://github.com/microsoft/vscode-jupyter/issues/9468#issuecomment-1078468039
2521
matplotlib
2622
ipympl
2723
traitlets==5.9.0 # https://github.com/microsoft/vscode-jupyter/issues/14338

src/standalone/api/api.jupyterProvider.vscode.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import { JupyterServer, JupyterServerProvider } from '../../api';
2727
import { openAndShowNotebook } from '../../platform/common/utils/notebooks';
2828
import { JupyterServer as JupyterServerStarter } from '../../test/datascience/jupyterServer.node';
29-
import { IS_REMOTE_NATIVE_TEST } from '../../test/constants';
29+
import { IS_CONDA_TEST, IS_REMOTE_NATIVE_TEST } from '../../test/constants';
3030
import { isWeb } from '../../platform/common/utils/misc';
3131
import { MultiStepInput } from '../../platform/common/utils/multiStepInput';
3232

@@ -43,6 +43,10 @@ suite('Jupyter Provider Tests', function () {
4343
if (IS_REMOTE_NATIVE_TEST() || isWeb()) {
4444
return this.skip();
4545
}
46+
if (IS_CONDA_TEST()) {
47+
// Due to upstream issue documented here https://github.com/microsoft/vscode-jupyter/issues/14338
48+
return this.skip();
49+
}
4650
this.timeout(120_000);
4751
api = await initialize();
4852
const tokenSource = new CancellationTokenSource();

src/standalone/userJupyterServer/userServerUrlProvider.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
} from '../../platform/common/types';
5050
import { Common, DataScience } from '../../platform/common/utils/localize';
5151
import { noop } from '../../platform/common/utils/misc';
52-
import { traceError, traceWarning } from '../../platform/logging';
52+
import { traceError, traceVerbose, traceWarning } from '../../platform/logging';
5353
import { JupyterPasswordConnect } from './jupyterPasswordConnect';
5454
import {
5555
IJupyterServerUri,
@@ -130,7 +130,7 @@ export class UserJupyterServerUrlProvider
130130
// eslint-disable-next-line @typescript-eslint/no-use-before-define
131131
this.secureConnectionValidator = new SecureConnectionValidator(applicationShell, globalMemento);
132132
// eslint-disable-next-line @typescript-eslint/no-use-before-define
133-
this.jupyterServerUriInput = new UserJupyterServerUriInput(clipboard, applicationShell);
133+
this.jupyterServerUriInput = new UserJupyterServerUriInput(clipboard, applicationShell, requestCreator);
134134
// eslint-disable-next-line @typescript-eslint/no-use-before-define
135135
this.jupyterServerUriDisplayName = new UserJupyterServerDisplayName(applicationShell);
136136
this.jupyterPasswordConnect = new JupyterPasswordConnect(
@@ -388,7 +388,7 @@ export class UserJupyterServerUrlProvider
388388
let initialUrlWasValid = false;
389389
if (initialUrl) {
390390
// Validate the URI first, which would otherwise be validated when user enters the Uri into the input box.
391-
const initialVerification = this.jupyterServerUriInput.parseUserUriAndGetValidationError(initialUrl);
391+
const initialVerification = await this.jupyterServerUriInput.parseUserUriAndGetValidationError(initialUrl);
392392
if (typeof initialVerification.validationError === 'string') {
393393
// Uri has an error, show the error message by displaying the input box and pre-populating the url.
394394
validationErrorMessage = initialVerification.validationError;
@@ -709,7 +709,8 @@ export class UserJupyterServerUrlProvider
709709
export class UserJupyterServerUriInput {
710710
constructor(
711711
@inject(IClipboard) private readonly clipboard: IClipboard,
712-
@inject(IApplicationShell) private readonly applicationShell: IApplicationShell
712+
@inject(IApplicationShell) private readonly applicationShell: IApplicationShell,
713+
@inject(IJupyterRequestCreator) private readonly requestCreator: IJupyterRequestCreator
713714
) {}
714715

715716
async getUrlFromUser(
@@ -753,7 +754,7 @@ export class UserJupyterServerUriInput {
753754
);
754755

755756
input.onDidAccept(async () => {
756-
const result = this.parseUserUriAndGetValidationError(input.value);
757+
const result = await this.parseUserUriAndGetValidationError(input.value);
757758
if (typeof result.validationError === 'string') {
758759
input.validationMessage = result.validationError;
759760
return;
@@ -763,22 +764,55 @@ export class UserJupyterServerUriInput {
763764
return deferred.promise;
764765
}
765766

766-
public parseUserUriAndGetValidationError(
767+
public async parseUserUriAndGetValidationError(
767768
value: string
768-
): { validationError: string } | { jupyterServerUri: IJupyterServerUri; url: string; validationError: undefined } {
769+
): Promise<
770+
{ validationError: string } | { jupyterServerUri: IJupyterServerUri; url: string; validationError: undefined }
771+
> {
769772
// If it ends with /lab? or /lab or /tree? or /tree, then remove that part.
770773
const uri = value.trim().replace(/\/(lab|tree)(\??)$/, '');
771774
const jupyterServerUri = parseUri(uri, '');
772775
if (!jupyterServerUri) {
773776
return { validationError: DataScience.jupyterSelectURIInvalidURI };
774777
}
778+
jupyterServerUri.baseUrl = (await getBaseJupyterUrl(uri, this.requestCreator)) || jupyterServerUri.baseUrl;
775779
if (!uri.toLowerCase().startsWith('http:') && !uri.toLowerCase().startsWith('https:')) {
776780
return { validationError: DataScience.jupyterSelectURIMustBeHttpOrHttps };
777781
}
778782
return { jupyterServerUri, url: uri, validationError: undefined };
779783
}
780784
}
781785

786+
export async function getBaseJupyterUrl(url: string, requestCreator: IJupyterRequestCreator) {
787+
// Jupyter URLs can contain a path, but we only want the base URL
788+
// E.g. user can enter http://localhost:8000/tree?token=1234
789+
// and we need http://localhost:8000/
790+
// Similarly user can enter http://localhost:8888/lab/workspaces/auto-R
791+
// or http://localhost:8888/notebooks/Untitled.ipynb?kernel_name=python3
792+
// In all of these cases, once we remove the token, and we make a request to the url
793+
// then the jupyter server will redirect the user the loging page
794+
// which is of the form http://localhost:8000/login?next....
795+
// And the base url is easily identifiable as what ever is before `login?`
796+
try {
797+
// parseUri has special handling of `tree?` and `lab?`
798+
// For some reasson Jupyter does not redirecto those the the a
799+
url = parseUri(url, '')?.baseUrl || url;
800+
if (new URL(url).pathname === '/') {
801+
// No need to make a request, as we already have the base url.
802+
return url;
803+
}
804+
const urlWithoutToken = url.indexOf('token=') > 0 ? url.substring(0, url.indexOf('token=')) : url;
805+
const fetch = requestCreator.getFetchMethod();
806+
const response = await fetch(urlWithoutToken, { method: 'GET', redirect: 'manual' });
807+
const loginPage = response.headers.get('location');
808+
if (loginPage && loginPage.includes('login?')) {
809+
return loginPage.substring(0, loginPage.indexOf('login?'));
810+
}
811+
} catch (ex) {
812+
traceVerbose(`Unable to identify the baseUrl of the Jupyter Server`, ex);
813+
}
814+
}
815+
782816
function sendRemoteTelemetryForAdditionOfNewRemoteServer(
783817
handle: string,
784818
baseUrl: string,

src/test/datascience/jupyter/connection.vscode.test.ts

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import {
1515
IExtensionContext
1616
} from '../../../platform/common/types';
1717
import { IS_REMOTE_NATIVE_TEST, initialize } from '../../initialize.node';
18-
import { startJupyterServer, closeNotebooksAndCleanUpAfterTests } from '../notebook/helper.node';
18+
import { startJupyterServer, closeNotebooksAndCleanUpAfterTests, hijackPrompt } from '../notebook/helper.node';
1919
import {
2020
SecureConnectionValidator,
2121
UserJupyterServerDisplayName,
2222
UserJupyterServerUriInput,
2323
UserJupyterServerUrlProvider,
24+
getBaseJupyterUrl,
2425
parseUri
2526
} from '../../../standalone/userJupyterServer/userServerUrlProvider';
2627
import {
@@ -33,7 +34,7 @@ import {
3334
import { JupyterConnection } from '../../../kernels/jupyter/connection/jupyterConnection';
3435
import { dispose } from '../../../platform/common/helpers';
3536
import { anything, instance, mock, when } from 'ts-mockito';
36-
import { CancellationTokenSource, Disposable, EventEmitter, InputBox, Memento } from 'vscode';
37+
import { CancellationTokenSource, Disposable, EventEmitter, InputBox, Memento, workspace } from 'vscode';
3738
import { noop } from '../../../platform/common/utils/misc';
3839
import { DataScience } from '../../../platform/common/utils/localize';
3940
import * as sinon from 'sinon';
@@ -42,9 +43,12 @@ import { createDeferred, createDeferredFromPromise } from '../../../platform/com
4243
import { IMultiStepInputFactory } from '../../../platform/common/utils/multiStepInput';
4344
import { IFileSystem } from '../../../platform/common/platform/types';
4445

45-
suite('Connect to Remote Jupyter Servers', function () {
46+
suite('Connect to Remote Jupyter Servers @mandatory', function () {
4647
// On conda these take longer for some reason.
4748
this.timeout(120_000);
49+
let jupyterNotebookWithAutoGeneratedToken = { url: '', dispose: noop };
50+
let jupyterLabWithAutoGeneratedToken = { url: '', dispose: noop };
51+
let jupyterNotebookWithCerts = { url: '', dispose: noop };
4852
let jupyterNotebookWithHelloPassword = { url: '', dispose: noop };
4953
let jupyterLabWithHelloPasswordAndWorldToken = { url: '', dispose: noop };
5054
let jupyterNotebookWithHelloToken = { url: '', dispose: noop };
@@ -57,12 +61,28 @@ suite('Connect to Remote Jupyter Servers', function () {
5761
this.timeout(120_000);
5862
await initialize();
5963
[
64+
jupyterNotebookWithAutoGeneratedToken,
65+
jupyterLabWithAutoGeneratedToken,
66+
jupyterNotebookWithCerts,
6067
jupyterNotebookWithHelloPassword,
6168
jupyterLabWithHelloPasswordAndWorldToken,
6269
jupyterNotebookWithHelloToken,
6370
jupyterNotebookWithEmptyPasswordToken,
6471
jupyterLabWithHelloPasswordAndEmptyToken
6572
] = await Promise.all([
73+
startJupyterServer({
74+
jupyterLab: false,
75+
standalone: true
76+
}),
77+
startJupyterServer({
78+
jupyterLab: true,
79+
standalone: true
80+
}),
81+
startJupyterServer({
82+
jupyterLab: false,
83+
standalone: true,
84+
useCert: true
85+
}),
6686
startJupyterServer({
6787
jupyterLab: false,
6888
password: 'Hello',
@@ -95,6 +115,8 @@ suite('Connect to Remote Jupyter Servers', function () {
95115
});
96116
suiteTeardown(() => {
97117
dispose([
118+
jupyterNotebookWithAutoGeneratedToken,
119+
jupyterLabWithAutoGeneratedToken,
98120
jupyterNotebookWithHelloPassword,
99121
jupyterLabWithHelloPasswordAndWorldToken,
100122
jupyterNotebookWithHelloToken,
@@ -111,6 +133,7 @@ suite('Connect to Remote Jupyter Servers', function () {
111133
let commands: ICommandManager;
112134
let inputBox: InputBox;
113135
let token: CancellationTokenSource;
136+
let requestCreator: IJupyterRequestCreator;
114137
setup(async function () {
115138
if (!IS_REMOTE_NATIVE_TEST()) {
116139
return this.skip();
@@ -165,6 +188,7 @@ suite('Connect to Remote Jupyter Servers', function () {
165188
const onDidRemoveUriStorage = new EventEmitter<JupyterServerProviderHandle[]>();
166189
disposables.push(onDidRemoveUriStorage);
167190
when(serverUriStorage.onDidRemove).thenReturn(onDidRemoveUriStorage.event);
191+
requestCreator = api.serviceContainer.get<IJupyterRequestCreator>(IJupyterRequestCreator);
168192

169193
userUriProvider = new UserJupyterServerUrlProvider(
170194
instance(clipboard),
@@ -198,7 +222,7 @@ suite('Connect to Remote Jupyter Servers', function () {
198222
});
199223
suiteTeardown(() => closeNotebooksAndCleanUpAfterTests(disposables));
200224

201-
async function testConnection({
225+
async function testConnectionAndVerifyBaseUrl({
202226
password,
203227
userUri,
204228
failWithInvalidPassword
@@ -207,12 +231,23 @@ suite('Connect to Remote Jupyter Servers', function () {
207231
userUri: string;
208232
failWithInvalidPassword?: boolean;
209233
}) {
234+
const config = workspace.getConfiguration('jupyter');
235+
await config.update('allowUnauthorizedRemoteConnection', false);
236+
const prompt = await hijackPrompt(
237+
'showErrorMessage',
238+
{ contains: 'certificate' },
239+
{ result: DataScience.jupyterSelfCertEnable, clickImmediately: true }
240+
);
241+
disposables.push(prompt);
210242
const displayName = 'Test Remove Server Name';
211243
when(clipboard.readText()).thenResolve(userUri);
212244
sinon.stub(UserJupyterServerUriInput.prototype, 'getUrlFromUser').resolves({
213245
url: userUri,
214246
jupyterServerUri: parseUri(userUri, '')!
215247
});
248+
const baseUrl = `${new URL(userUri).protocol}//localhost:${new URL(userUri).port}/`;
249+
const computedBaseUrl = await getBaseJupyterUrl(userUri, requestCreator);
250+
assert.strictEqual(computedBaseUrl?.endsWith('/') ? computedBaseUrl : `${computedBaseUrl}/`, baseUrl);
216251
sinon.stub(SecureConnectionValidator.prototype, 'promptToUseInsecureConnections').resolves(true);
217252
sinon.stub(UserJupyterServerDisplayName.prototype, 'getDisplayName').resolves(displayName);
218253
const errorMessageDisplayed = createDeferred<string>();
@@ -226,6 +261,9 @@ suite('Connect to Remote Jupyter Servers', function () {
226261
assert.strictEqual(errorMessageDisplayed.value, DataScience.passwordFailure);
227262
assert.ok(!handlePromise.completed);
228263
} else {
264+
if (new URL(userUri).protocol.includes('https')) {
265+
assert.ok(await prompt.displayed, 'Prompt for trusting certs not displayed');
266+
}
229267
assert.equal(errorMessageDisplayed.value || '', '', `Password should be valid, ${errorMessageDisplayed}`);
230268
assert.ok(handlePromise.completed, 'Did not complete');
231269
const value = handlePromise.value;
@@ -255,42 +293,80 @@ suite('Connect to Remote Jupyter Servers', function () {
255293
// assert.strictEqual(serverInfo.displayName, `Title of Server`, 'Invalid Title');
256294
}
257295
}
296+
test('Connect to server with auto generated Token in URL', () =>
297+
testConnectionAndVerifyBaseUrl({ userUri: jupyterNotebookWithAutoGeneratedToken.url, password: undefined }));
298+
test('Connect to JuyterLab server with auto generated Token in URL', () =>
299+
testConnectionAndVerifyBaseUrl({ userUri: jupyterLabWithAutoGeneratedToken.url, password: undefined }));
300+
test('Connect to server with certificates', () =>
301+
testConnectionAndVerifyBaseUrl({ userUri: jupyterNotebookWithCerts.url, password: undefined }));
302+
test('Connect to server with auto generated Token in URL and path has tree in it', async () => {
303+
const token = new URL(jupyterNotebookWithAutoGeneratedToken.url).searchParams.get('token')!;
304+
const port = new URL(jupyterNotebookWithAutoGeneratedToken.url).port;
305+
await testConnectionAndVerifyBaseUrl({
306+
userUri: `http://localhost:${port}/tree?token=${token}`,
307+
password: undefined
308+
});
309+
});
310+
test('Connect to server with auto generated Token in URL and custom path', async () => {
311+
const token = new URL(jupyterLabWithAutoGeneratedToken.url).searchParams.get('token')!;
312+
const port = new URL(jupyterLabWithAutoGeneratedToken.url).port;
313+
await testConnectionAndVerifyBaseUrl({
314+
userUri: `http://localhost:${port}/notebooks/Untitled.ipynb?kernel_name=python3&token=${token}`,
315+
password: undefined
316+
});
317+
});
318+
test('Connect to Jupyter Lab server with auto generated Token in URL and path has lab in it', async () => {
319+
const token = new URL(jupyterLabWithAutoGeneratedToken.url).searchParams.get('token')!;
320+
const port = new URL(jupyterLabWithAutoGeneratedToken.url).port;
321+
await testConnectionAndVerifyBaseUrl({
322+
userUri: `http://localhost:${port}/lab?token=${token}`,
323+
password: undefined
324+
});
325+
});
326+
test('Connect to Jupyter Lab server with auto generated Token in URL and custom path', async () => {
327+
const token = new URL(jupyterLabWithAutoGeneratedToken.url).searchParams.get('token')!;
328+
const port = new URL(jupyterLabWithAutoGeneratedToken.url).port;
329+
await testConnectionAndVerifyBaseUrl({
330+
userUri: `http://localhost:${port}/lab/workspaces/auto-R?token=${token}`,
331+
password: undefined
332+
});
333+
});
258334
test('Connect to server with Token in URL', () =>
259-
testConnection({ userUri: jupyterNotebookWithHelloToken.url, password: undefined }));
335+
testConnectionAndVerifyBaseUrl({ userUri: jupyterNotebookWithHelloToken.url, password: undefined }));
260336
test('Connect to server with Password and Token in URL', () =>
261-
testConnection({ userUri: jupyterNotebookWithHelloPassword.url, password: 'Hello' }));
337+
testConnectionAndVerifyBaseUrl({ userUri: jupyterNotebookWithHelloPassword.url, password: 'Hello' }));
262338
test('Connect to Notebook server with Password and no Token in URL', () =>
263-
testConnection({
339+
testConnectionAndVerifyBaseUrl({
264340
userUri: `http://localhost:${new URL(jupyterNotebookWithHelloPassword.url).port}/`,
265341
password: 'Hello'
266342
}));
267343
test('Connect to Lab server with Password and no Token in URL', () =>
268-
testConnection({
344+
testConnectionAndVerifyBaseUrl({
269345
userUri: `http://localhost:${new URL(jupyterLabWithHelloPasswordAndWorldToken.url).port}/`,
270346
password: 'Hello'
271347
}));
272348
test('Connect to server with Invalid Password', () =>
273-
testConnection({
349+
testConnectionAndVerifyBaseUrl({
274350
userUri: `http://localhost:${new URL(jupyterNotebookWithHelloPassword.url).port}/`,
275351
password: 'Bogus',
276352
failWithInvalidPassword: true
277353
}));
278354
test('Connect to Lab server with Password & Token in URL', async () =>
279-
testConnection({ userUri: jupyterLabWithHelloPasswordAndWorldToken.url, password: 'Hello' }));
355+
testConnectionAndVerifyBaseUrl({ userUri: jupyterLabWithHelloPasswordAndWorldToken.url, password: 'Hello' }));
280356
test('Connect to server with empty Password & empty Token in URL', () =>
281-
testConnection({ userUri: jupyterNotebookWithEmptyPasswordToken.url, password: '' }));
357+
testConnectionAndVerifyBaseUrl({ userUri: jupyterNotebookWithEmptyPasswordToken.url, password: '' }));
282358
test('Connect to server with empty Password & empty Token (nothing in URL)', () =>
283-
testConnection({
359+
testConnectionAndVerifyBaseUrl({
284360
userUri: `http://localhost:${new URL(jupyterNotebookWithEmptyPasswordToken.url).port}/`,
285361
password: ''
286362
}));
287363
test('Connect to Lab server with Hello Password & empty Token (not even in URL)', () =>
288-
testConnection({
364+
testConnectionAndVerifyBaseUrl({
289365
userUri: `http://localhost:${new URL(jupyterLabWithHelloPasswordAndEmptyToken.url).port}/`,
290366
password: 'Hello'
291367
}));
292368
test('Connect to Lab server with bogus Password & empty Token (not even in URL)', () =>
293-
testConnection({
369+
testConnectionAndVerifyBaseUrl({
294370
userUri: `http://localhost:${new URL(jupyterLabWithHelloPasswordAndEmptyToken.url).port}/`,
295371
password: 'Bogus',
296372
failWithInvalidPassword: true

0 commit comments

Comments
 (0)