Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create new GitHub Authentication Provider extension #5400

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Binary file added extensions/github/icon.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions extensions/github/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "github",
"displayName": "GitHub",
"description": "GitHub Authentication Provider",
"version": "0.0.1",
"icon": "icon.png",
"publisher": "podman-desktop",
"license": "Apache-2.0",
"engines": {
"podman-desktop": "^0.0.1"
},
"main": "./dist/extension.js",
"contributes": {
"configuration": {
"title": "GitHub",
"properties": {
"github.token": {
"type": "string",
"default": "",
"markdownDescription": "GitHub token to act as an authenficated user. You can create one on [github.com/settings/token](https://github.com/settings/tokens/new)."
}
}
},
"menus": {},
"views": {},
"commands": []
},
"scripts": {
"build": "vite build && node ./scripts/build.js",
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage",
"watch": "vite build -w"
},
"dependencies": {
"@octokit/rest": "^20.0.2",
"@podman-desktop/api": "^0.0.1",
"mustache": "^4.2.0",
"yaml": "^2.3.4"
},
"devDependencies": {
"adm-zip": "^0.5.10",
"mkdirp": "^3.0.1",
"tmp-promise": "^3.0.3",
"vite": "^5.0.10",
"vitest": "^1.1.1"
}
}
46 changes: 46 additions & 0 deletions extensions/github/scripts/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**********************************************************************
* Copyright (C) 2022-2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

const AdmZip = require('adm-zip');
const path = require('path');
const packageJson = require('../package.json');
const fs = require('fs');
const { mkdirp } = require('mkdirp');

const destFile = path.resolve(__dirname, `../${packageJson.name}.cdix`);
const builtinDirectory = path.resolve(__dirname, '../builtin');
const unzippedDirectory = path.resolve(builtinDirectory, `${packageJson.name}.cdix`);
// remove the .cdix file before zipping
if (fs.existsSync(destFile)) {
fs.rmSync(destFile);
}
// remove the builtin folder before zipping
if (fs.existsSync(builtinDirectory)) {
fs.rmSync(builtinDirectory, { recursive: true, force: true });
}

const zip = new AdmZip();
zip.addLocalFolder(path.resolve(__dirname, '../'));
zip.writeZip(destFile);

// create unzipped built-in
mkdirp(unzippedDirectory).then(() => {
const unzip = new AdmZip(destFile);
unzip.extractAllTo(unzippedDirectory);
});
178 changes: 178 additions & 0 deletions extensions/github/src/extension.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { activate, getGitHubAccessToken, GitHubProvider } from './extension';
import * as extensionApi from '@podman-desktop/api';
import type {
AuthenticationProvider,
AuthenticationProviderOptions,
Configuration,
TelemetryLogger,
} from '@podman-desktop/api';

const mocks = vi.hoisted(() => ({
push: vi.fn(),
}));

// Mock telemetry
const telemetryLogUsageMock = vi.fn();
const telemetryLogErrorMock = vi.fn();
const telemetryLoggerMock = {
logUsage: telemetryLogUsageMock,
logError: telemetryLogErrorMock,
} as unknown as TelemetryLogger;

const extensionContextMock = {
subscriptions: {
push: mocks.push,
},
} as unknown as extensionApi.ExtensionContext;

// Mock configurable properties
function fakeConfiguration(accessToken?: string): Configuration {
return {
get(section: string, defaultValue?: string): string | undefined {
return section === 'token' ? accessToken || defaultValue : defaultValue;
},
has(): boolean {
throw new Error('not implemented');
},
update(): Promise<void> {
throw new Error('not implemented');
},
};
}

const fireMock = vi.hoisted(() => vi.fn());

vi.mock('@podman-desktop/api', async () => {
return {
EventEmitter: vi.fn().mockReturnValue({
fire: fireMock,
}),
configuration: {
getConfiguration: vi.fn(),
},
authentication: {
registerAuthenticationProvider: vi.fn(),
},
env: {
createTelemetryLogger: vi.fn().mockImplementation(() => telemetryLoggerMock),
},
window: {
showErrorMessage: vi.fn(),
},
};
});

beforeEach(() => {
vi.clearAllMocks();
});

vi.mock('@octokit/rest', () => {
const users = {
getAuthenticated: vi.fn().mockReturnValue({
data: { id: 'mocked', name: '@mocked' },
}),
};
return {
Octokit: vi.fn().mockReturnValue({ users: users }),
};
});

test('expect token configuration undefined', async () => {
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration());
expect(getGitHubAccessToken()).toBeUndefined();
});

test('expect token configuration defined', async () => {
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration('dummy'));
expect(getGitHubAccessToken()).toBe('dummy');
});

test('error: cannot create session: empty access token', async () => {
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration());

const authProvider = new GitHubProvider(telemetryLoggerMock);

// createSession without access token configured
try {
await authProvider.createSession([]);
// Expect that showErrorMessage is called
expect(extensionApi.window.showErrorMessage).toHaveBeenCalled();
} catch (err) {
expect(err).to.be.a('Error');
expect(err).toBeDefined();
}
});

test('valid access token', async () => {
// Configured access token
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration('dummy'));

const authProvider = new GitHubProvider(telemetryLoggerMock);

// createSession without access token configured
const session = await authProvider.createSession([]);
expect(session).toBeDefined();
expect(session.accessToken).toBe('dummy');
expect(fireMock).toHaveBeenNthCalledWith(1, {
added: [session],
});
});

test('expect createSession to be called automatically', async () => {
// We have a configured access token value
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration('dummy'));

let provider: AuthenticationProvider | undefined = undefined;
vi.spyOn(extensionApi.authentication, 'registerAuthenticationProvider').mockImplementation(
(_id: string, _label: string, nProvider: AuthenticationProvider, _options?: AuthenticationProviderOptions) => {
provider = nProvider;
return undefined;
},
);

await activate(extensionContextMock);
expect(provider).toBeDefined();
expect(await provider.getSessions([])).toHaveLength(1);
expect(mocks.push).toHaveBeenCalledOnce();
});

test('expect createSession not to be called automatically', async () => {
// We DO NOT have a configured access token value
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration());

let provider: AuthenticationProvider | undefined = undefined;
vi.spyOn(extensionApi.authentication, 'registerAuthenticationProvider').mockImplementation(
(_id: string, _label: string, nProvider: AuthenticationProvider, _options?: AuthenticationProviderOptions) => {
provider = nProvider;
return undefined;
},
);

await activate(extensionContextMock);
expect(provider).toBeDefined();
expect(await provider.getSessions([])).toHaveLength(0);
});

test('expect session removed properly', async () => {
vi.spyOn(extensionApi.configuration, 'getConfiguration').mockReturnValue(fakeConfiguration('dummy'));

const authProvider = new GitHubProvider(telemetryLoggerMock);

// createSession with access token configured
const session = await authProvider.createSession([]);
// Ensure the session is created and event fired
expect(await authProvider.getSessions()).toHaveLength(1);
expect(fireMock).toHaveBeenNthCalledWith(1, {
added: [session],
});

// Reset the fire mock
fireMock.mockReset();
await authProvider.removeSession(session.id);

expect(await authProvider.getSessions()).toHaveLength(0);
expect(fireMock).toHaveBeenNthCalledWith(1, {
removed: [session],
});
});