diff --git a/.vscode-test.js b/.vscode-test.js index e09b8443b7f..6846ca522f6 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -31,6 +31,11 @@ const extensions = [ workspaceFolder: path.join(os.tmpdir(), `nbout-${Math.floor(Math.random() * 100000)}`), mocha: { timeout: 60_000 } }, + { + label: 'github-authentication', + workspaceFolder: path.join(os.tmpdir(), `msft-auth-${Math.floor(Math.random() * 100000)}`), + mocha: { timeout: 60_000 } + } ]; diff --git a/extensions/github-authentication/package.json b/extensions/github-authentication/package.json index c1e13b86e2a..a57586ec9f0 100644 --- a/extensions/github-authentication/package.json +++ b/extensions/github-authentication/package.json @@ -64,6 +64,7 @@ "vscode-tas-client": "^0.1.47" }, "devDependencies": { + "@types/mocha": "^9.1.1", "@types/node": "18.x", "@types/node-fetch": "^2.5.7" }, diff --git a/extensions/github-authentication/src/flows.ts b/extensions/github-authentication/src/flows.ts index 1e988d92d30..3641ffb3a36 100644 --- a/extensions/github-authentication/src/flows.ts +++ b/extensions/github-authentication/src/flows.ts @@ -53,7 +53,7 @@ export const enum ExtensionHost { Local } -interface IFlowQuery { +export interface IFlowQuery { target: GitHubTarget; extensionHost: ExtensionHost; isSupportedClient: boolean; diff --git a/extensions/github-authentication/src/test/flows.test.ts b/extensions/github-authentication/src/test/flows.test.ts new file mode 100644 index 00000000000..7f4963f4bd5 --- /dev/null +++ b/extensions/github-authentication/src/test/flows.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ExtensionHost, GitHubTarget, IFlowQuery, getFlows } from '../flows'; +import { Config } from '../config'; + +const enum Flows { + UrlHandlerFlow = 'url handler', + LocalServerFlow = 'local server', + DeviceCodeFlow = 'device code', + PatFlow = 'personal access token' +} + +suite('getFlows', () => { + let lastClientSecret: string | undefined = undefined; + suiteSetup(() => { + lastClientSecret = Config.gitHubClientSecret; + Config.gitHubClientSecret = 'asdf'; + }); + + suiteTeardown(() => { + Config.gitHubClientSecret = lastClientSecret; + }); + + const testCases: Array<{ label: string; query: IFlowQuery; expectedFlows: Flows[] }> = [ + { + label: 'VS Code Desktop. Local filesystem. GitHub.com', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: true, + target: GitHubTarget.DotCom + }, + expectedFlows: [ + Flows.UrlHandlerFlow, + Flows.LocalServerFlow, + Flows.DeviceCodeFlow + ] + }, + { + label: 'VS Code Desktop. Local filesystem. GitHub Hosted Enterprise', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: true, + target: GitHubTarget.HostedEnterprise + }, + expectedFlows: [ + Flows.UrlHandlerFlow, + Flows.LocalServerFlow, + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'VS Code Desktop. Local filesystem. GitHub Enterprise Server', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: true, + target: GitHubTarget.Enterprise + }, + expectedFlows: [ + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'vscode.dev. serverful. GitHub.com', + query: { + extensionHost: ExtensionHost.Remote, + isSupportedClient: true, + target: GitHubTarget.DotCom + }, + expectedFlows: [ + Flows.UrlHandlerFlow, + Flows.DeviceCodeFlow + ] + }, + { + label: 'vscode.dev. serverful. GitHub Hosted Enterprise', + query: { + extensionHost: ExtensionHost.Remote, + isSupportedClient: true, + target: GitHubTarget.HostedEnterprise + }, + expectedFlows: [ + Flows.UrlHandlerFlow, + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'vscode.dev. serverful. GitHub Enterprise', + query: { + extensionHost: ExtensionHost.Remote, + isSupportedClient: true, + target: GitHubTarget.Enterprise + }, + expectedFlows: [ + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'vscode.dev. serverless. GitHub.com', + query: { + extensionHost: ExtensionHost.WebWorker, + isSupportedClient: true, + target: GitHubTarget.DotCom + }, + expectedFlows: [ + Flows.UrlHandlerFlow + ] + }, + { + label: 'vscode.dev. serverless. GitHub Hosted Enterprise', + query: { + extensionHost: ExtensionHost.WebWorker, + isSupportedClient: true, + target: GitHubTarget.HostedEnterprise + }, + expectedFlows: [ + Flows.UrlHandlerFlow, + Flows.PatFlow + ] + }, + { + label: 'vscode.dev. serverless. GitHub Enterprise Server', + query: { + extensionHost: ExtensionHost.WebWorker, + isSupportedClient: true, + target: GitHubTarget.Enterprise + }, + expectedFlows: [ + Flows.PatFlow + ] + }, + { + label: 'Code - OSS. Local filesystem. GitHub.com', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: false, + target: GitHubTarget.DotCom + }, + expectedFlows: [ + Flows.LocalServerFlow, + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'Code - OSS. Local filesystem. GitHub Hosted Enterprise', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: false, + target: GitHubTarget.HostedEnterprise + }, + expectedFlows: [ + Flows.LocalServerFlow, + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + { + label: 'Code - OSS. Local filesystem. GitHub Enterprise Server', + query: { + extensionHost: ExtensionHost.Local, + isSupportedClient: false, + target: GitHubTarget.Enterprise + }, + expectedFlows: [ + Flows.DeviceCodeFlow, + Flows.PatFlow + ] + }, + ]; + + for (const testCase of testCases) { + test(`gives the correct flows - ${testCase.label}`, () => { + const flows = getFlows(testCase.query); + + assert.strictEqual( + flows.length, + testCase.expectedFlows.length, + `Unexpected number of flows: ${flows.map(f => f.label).join(',')}` + ); + + for (let i = 0; i < flows.length; i++) { + const flow = flows[i]; + + assert.strictEqual(flow.label, testCase.expectedFlows[i]); + } + }); + } +}); diff --git a/extensions/github-authentication/src/test/node/authServer.test.ts b/extensions/github-authentication/src/test/node/authServer.test.ts new file mode 100644 index 00000000000..6de8da61fda --- /dev/null +++ b/extensions/github-authentication/src/test/node/authServer.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { LoopbackAuthServer } from '../../node/authServer'; + +suite('LoopbackAuthServer', () => { + let server: LoopbackAuthServer; + let port: number; + + setup(async () => { + server = new LoopbackAuthServer(__dirname, 'http://localhost:8080'); + port = await server.start(); + }); + + teardown(async () => { + await server.stop(); + }); + + test('should redirect to starting redirect on /signin', async () => { + const response = await fetch(`http://localhost:${port}/signin?nonce=${server.nonce}`, { + redirect: 'manual' + }); + // Redirect + assert.strictEqual(response.status, 302); + + // Check location + const location = response.headers.get('location'); + assert.ok(location); + const locationUrl = new URL(location); + assert.strictEqual(locationUrl.origin, 'http://localhost:8080'); + + // Check state + const state = locationUrl.searchParams.get('state'); + assert.ok(state); + const stateLocation = new URL(state); + assert.strictEqual(stateLocation.origin, `http://127.0.0.1:${port}`); + assert.strictEqual(stateLocation.pathname, '/callback'); + assert.strictEqual(stateLocation.searchParams.get('nonce'), server.nonce); + }); + + test('should return 400 on /callback with missing parameters', async () => { + const response = await fetch(`http://localhost:${port}/callback`); + assert.strictEqual(response.status, 400); + }); + + test('should resolve with code and state on /callback with valid parameters', async () => { + server.state = 'valid-state'; + const response = await fetch( + `http://localhost:${port}/callback?code=valid-code&state=${server.state}&nonce=${server.nonce}`, + { redirect: 'manual' } + ); + assert.strictEqual(response.status, 302); + assert.strictEqual(response.headers.get('location'), '/'); + await Promise.race([ + server.waitForOAuthResponse().then(result => { + assert.strictEqual(result.code, 'valid-code'); + assert.strictEqual(result.state, server.state); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)) + ]); + }); +}); diff --git a/extensions/github-authentication/yarn.lock b/extensions/github-authentication/yarn.lock index e8c7997aa38..1a2b9b273f1 100644 --- a/extensions/github-authentication/yarn.lock +++ b/extensions/github-authentication/yarn.lock @@ -259,6 +259,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/mocha@^9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" + integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== + "@types/node-fetch@^2.5.7": version "2.5.7" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index 1834f26162d..4786c7f7a6d 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -92,6 +92,11 @@ mkdir %CFWORKSPACE% call "%INTEGRATION_TEST_ELECTRON_PATH%" %CFWORKSPACE% --extensionDevelopmentPath=%~dp0\..\extensions\configuration-editing --extensionTestsPath=%~dp0\..\extensions\configuration-editing\out\test %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +echo. +echo ### GitHub Authentication tests +call yarn test-extension -l github-authentication +if %errorlevel% neq 0 exit /b %errorlevel% + :: Tests standalone (CommonJS) echo. diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index 6a7a1fe4a75..ab32efc798f 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -112,6 +112,11 @@ echo "$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/configuration-editing --extensionTestsPath=$ROOT/extensions/configuration-editing/out/test $API_TESTS_EXTRA_ARGS kill_app +echo +echo "### GitHub Authentication tests" +echo +yarn test-extension -l github-authentication +kill_app # Tests standalone (CommonJS)