Initial Move from Azure DevOps to GitHub

This commit is contained in:
Steve Faulkner
2020-05-25 21:30:55 -05:00
commit 36581fb6d9
986 changed files with 195242 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import { GitHubClient, IGitHubBranch, IGitHubRepo } from "./GitHubClient";
const invalidTokenCallback = jest.fn();
// Use a dummy token to get around API rate limit (same as AZURESAMPLESCOSMOSDBPAT in webpack.config.js)
const gitHubClient = new GitHubClient("99e38770e29b4a61d7c49f188780504efd35cc86", invalidTokenCallback);
const samplesRepo: IGitHubRepo = {
name: "cosmos-notebooks",
owner: {
login: "Azure-Samples"
},
private: false
};
const samplesBranch: IGitHubBranch = {
name: "master"
};
const sampleFilePath = ".gitignore";
const sampleDirPath = ".github";
describe.skip("GitHubClient", () => {
it("getRepoAsync returns valid repo", async () => {
const response = await gitHubClient.getRepoAsync(samplesRepo.owner.login, samplesRepo.name);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.name).toBe(samplesRepo.name);
expect(response.data.owner.login).toBe(samplesRepo.owner.login);
});
it("getReposAsync returns repos for authenticated user", async () => {
const response = await gitHubClient.getReposAsync(1, 1);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.length).toBe(1);
});
it("getBranchesAsync returns branches for a repo", async () => {
const response = await gitHubClient.getBranchesAsync(samplesRepo.owner.login, samplesRepo.name, 1, 1);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.length).toBe(1);
});
it("getCommitsAsync returns commits for a file", async () => {
const response = await gitHubClient.getCommitsAsync(
samplesRepo.owner.login,
samplesRepo.name,
samplesBranch.name,
sampleFilePath,
1,
1
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.length).toBe(1);
});
it("getDirContentsAsync returns files in the repo", async () => {
const response = await gitHubClient.getDirContentsAsync(
samplesRepo.owner.login,
samplesRepo.name,
samplesBranch.name,
""
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.length).toBeGreaterThan(0);
expect(response.data[0].repo).toEqual(samplesRepo);
expect(response.data[0].branch).toEqual(samplesBranch);
});
it("getDirContentsAsync returns files in a dir", async () => {
const response = await gitHubClient.getDirContentsAsync(
samplesRepo.owner.login,
samplesRepo.name,
samplesBranch.name,
sampleDirPath
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.length).toBeGreaterThan(0);
expect(response.data[0].repo).toEqual(samplesRepo);
expect(response.data[0].branch).toEqual(samplesBranch);
});
it("getFileContentsAsync returns a file", async () => {
const response = await gitHubClient.getFileContentsAsync(
samplesRepo.owner.login,
samplesRepo.name,
samplesBranch.name,
sampleFilePath
);
expect(response.status).toBe(HttpStatusCodes.OK);
expect(response.data.path).toBe(sampleFilePath);
expect(response.data.repo).toEqual(samplesRepo);
expect(response.data.branch).toEqual(samplesBranch);
});
});

528
src/GitHub/GitHubClient.ts Normal file
View File

@@ -0,0 +1,528 @@
import { Octokit } from "@octokit/rest";
import { RequestHeaders } from "@octokit/types";
import { HttpStatusCodes } from "../Common/Constants";
export interface IGitHubResponse<T> {
status: number;
data: T;
}
export interface IGitHubRepo {
// API properties
name: string;
owner: {
login: string;
};
private: boolean;
// Custom properties
children?: IGitHubFile[];
}
export interface IGitHubFile {
// API properties
type: "file" | "dir" | "symlink" | "submodule";
encoding?: string;
size: number;
name: string;
path: string;
content?: string;
sha: string;
url: string;
html_url: string;
// Custom properties
children?: IGitHubFile[];
repo?: IGitHubRepo;
branch?: IGitHubBranch;
}
export interface IGitHubCommit {
// API properties
sha: string;
message: string;
committer: {
date: string;
};
}
export interface IGitHubBranch {
// API properties
name: string;
}
export interface IGitHubUser {
// API properties
login: string;
name: string;
}
export class GitHubClient {
private static readonly gitHubApiEndpoint = "https://api.github.com";
private static readonly samplesRepo: IGitHubRepo = {
name: "cosmos-notebooks",
private: false,
owner: {
login: "Azure-Samples"
}
};
private static readonly samplesTopCommit: IGitHubCommit = {
sha: "41b964f442b638097a75a3f3b6a6451db05a12bf",
committer: {
date: "2020-05-19T05:03:30Z"
},
message: "Fixing formatting"
};
private static readonly samplesFiles: IGitHubFile[] = [
{
name: ".github",
path: ".github",
sha: "5e6794a8177a0c07a8719f6e1d7b41cce6f92e1e",
size: 0,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/.github?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/tree/master/.github",
type: "dir"
},
{
name: ".gitignore",
path: ".gitignore",
sha: "3e759b75bf455ac809d0987d369aab89137b5689",
size: 5582,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/.gitignore?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/.gitignore",
type: "file"
},
{
name: "1. GettingStarted.ipynb",
path: "1. GettingStarted.ipynb",
sha: "0732ff5366e4aefdc4c378c61cbd968664f0acec",
size: 3933,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/1.%20GettingStarted.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/1.%20GettingStarted.ipynb",
type: "file"
},
{
name: "2. Visualization.ipynb",
path: "2. Visualization.ipynb",
sha: "f480134ac4adf2f50ce5fe66836c6966749d3ca1",
size: 814261,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/2.%20Visualization.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/2.%20Visualization.ipynb",
type: "file"
},
{
name: "3. RequestUnits.ipynb",
path: "3. RequestUnits.ipynb",
sha: "252b79a4adc81e9f2ffde453231b695d75e270e8",
size: 9490,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/3.%20RequestUnits.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/3.%20RequestUnits.ipynb",
type: "file"
},
{
name: "4. Indexing.ipynb",
path: "4. Indexing.ipynb",
sha: "e10dd67bd1c55c345226769e4f80e43659ef9cd5",
size: 10394,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/4.%20Indexing.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/4.%20Indexing.ipynb",
type: "file"
},
{
name: "5. StoredProcedures.ipynb",
path: "5. StoredProcedures.ipynb",
sha: "949941949920de4d2d111149e2182e9657cc8134",
size: 11818,
url:
"https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/5.%20StoredProcedures.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/5.%20StoredProcedures.ipynb",
type: "file"
},
{
name: "6. GlobalDistribution.ipynb",
path: "6. GlobalDistribution.ipynb",
sha: "b91c31dacacbc9e35750d9054063dda4a5309f3b",
size: 11375,
url:
"https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/6.%20GlobalDistribution.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/6.%20GlobalDistribution.ipynb",
type: "file"
},
{
name: "7. IoTAnomalyDetection.ipynb",
path: "7. IoTAnomalyDetection.ipynb",
sha: "82057ae52a67721a5966e2361317f5dfbd0ee595",
size: 377939,
url:
"https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/7.%20IoTAnomalyDetection.ipynb?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/blob/master/7.%20IoTAnomalyDetection.ipynb",
type: "file"
},
{
name: "All_API_quickstarts",
path: "All_API_quickstarts",
sha: "07054293e6c8fc00771fccd0cde207f5c8053978",
size: 0,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/All_API_quickstarts?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/tree/master/All_API_quickstarts",
type: "dir"
},
{
name: "CSharp_quickstarts",
path: "CSharp_quickstarts",
sha: "10e7f5704e6b56a40cac74bc39f15b7708954f52",
size: 0,
url: "https://api.github.com/repos/Azure-Samples/cosmos-notebooks/contents/CSharp_quickstarts?ref=master",
html_url: "https://github.com/Azure-Samples/cosmos-notebooks/tree/master/CSharp_quickstarts",
type: "dir"
}
];
private ocktokit: Octokit;
constructor(token: string, private errorCallback: (error: any) => void) {
this.initOctokit(token);
}
public setToken(token: string): void {
this.initOctokit(token);
}
public async getRepoAsync(owner: string, repo: string): Promise<IGitHubResponse<IGitHubRepo>> {
if (GitHubClient.isSamplesCall(owner, repo)) {
return {
status: HttpStatusCodes.OK,
data: GitHubClient.samplesRepo
};
}
const response = await this.ocktokit.repos.get({
owner,
repo,
headers: GitHubClient.getDisableCacheHeaders()
});
let data: IGitHubRepo;
if (response.data) {
data = GitHubClient.toGitHubRepo(response.data);
}
return { status: response.status, data };
}
public async getReposAsync(page: number, perPage: number): Promise<IGitHubResponse<IGitHubRepo[]>> {
const response = await this.ocktokit.repos.listForAuthenticatedUser({
page,
per_page: perPage,
headers: GitHubClient.getDisableCacheHeaders()
});
let data: IGitHubRepo[];
if (response.data) {
data = [];
response.data?.forEach((element: any) => data.push(GitHubClient.toGitHubRepo(element)));
}
return { status: response.status, data };
}
public async getBranchesAsync(
owner: string,
repo: string,
page: number,
perPage: number
): Promise<IGitHubResponse<IGitHubBranch[]>> {
const response = await this.ocktokit.repos.listBranches({
owner,
repo,
page,
per_page: perPage,
headers: GitHubClient.getDisableCacheHeaders()
});
let data: IGitHubBranch[];
if (response.data) {
data = [];
response.data?.forEach(element => data.push(GitHubClient.toGitHubBranch(element)));
}
return { status: response.status, data };
}
public async getCommitsAsync(
owner: string,
repo: string,
branch: string,
path: string,
page: number,
perPage: number
): Promise<IGitHubResponse<IGitHubCommit[]>> {
if (GitHubClient.isSamplesCall(owner, repo, branch) && path === "" && page === 1 && perPage === 1) {
return {
status: HttpStatusCodes.OK,
data: [GitHubClient.samplesTopCommit]
};
}
const response = await this.ocktokit.repos.listCommits({
owner,
repo,
sha: branch,
path,
page,
per_page: perPage,
headers: GitHubClient.getDisableCacheHeaders()
});
let data: IGitHubCommit[];
if (response.data) {
data = [];
response.data?.forEach(element =>
data.push(GitHubClient.toGitHubCommit({ ...element.commit, sha: element.sha }))
);
}
return { status: response.status, data };
}
public async getDirContentsAsync(
owner: string,
repo: string,
branch: string,
path: string
): Promise<IGitHubResponse<IGitHubFile[]>> {
return (await this.getContentsAsync(owner, repo, branch, path)) as IGitHubResponse<IGitHubFile[]>;
}
public async getFileContentsAsync(
owner: string,
repo: string,
branch: string,
path: string
): Promise<IGitHubResponse<IGitHubFile>> {
return (await this.getContentsAsync(owner, repo, branch, path)) as IGitHubResponse<IGitHubFile>;
}
public async getContentsAsync(
owner: string,
repo: string,
branch: string,
path: string
): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
if (GitHubClient.isSamplesCall(owner, repo, branch) && path === "") {
return {
status: HttpStatusCodes.OK,
data: GitHubClient.samplesFiles
};
}
const response = await this.ocktokit.repos.getContents({
owner,
repo,
path,
ref: branch,
headers: GitHubClient.getDisableCacheHeaders()
});
let data: IGitHubFile | IGitHubFile[];
if (response.data) {
const repoResponse = await this.getRepoAsync(owner, repo);
if (repoResponse.data) {
const fileRepo: IGitHubRepo = GitHubClient.toGitHubRepo(repoResponse.data);
const fileBranch: IGitHubBranch = { name: branch };
if (Array.isArray(response.data)) {
const contents: IGitHubFile[] = [];
response.data.forEach((element: any) =>
contents.push(GitHubClient.toGitHubFile(element, fileRepo, fileBranch))
);
data = contents;
} else {
data = GitHubClient.toGitHubFile(
{ ...response.data, type: response.data.type as "file" | "dir" | "symlink" | "submodule" },
fileRepo,
fileBranch
);
}
}
}
return { status: response.status, data };
}
public async createOrUpdateFileAsync(
owner: string,
repo: string,
branch: string,
path: string,
message: string,
content: string,
sha?: string
): Promise<IGitHubResponse<IGitHubCommit>> {
const response = await this.ocktokit.repos.createOrUpdateFile({
owner,
repo,
branch,
path,
message,
content,
sha
});
let data: IGitHubCommit;
if (response.data) {
data = GitHubClient.toGitHubCommit(response.data.commit);
}
return { status: response.status, data };
}
public async renameFileAsync(
owner: string,
repo: string,
branch: string,
message: string,
oldPath: string,
newPath: string
): Promise<IGitHubResponse<IGitHubCommit>> {
const ref = `heads/${branch}`;
const currentRef = await this.ocktokit.git.getRef({
owner,
repo,
ref,
headers: GitHubClient.getDisableCacheHeaders()
});
const currentTree = await this.ocktokit.git.getTree({
owner,
repo,
tree_sha: currentRef.data.object.sha,
recursive: "1",
headers: GitHubClient.getDisableCacheHeaders()
});
// API infers tree from paths so we need to filter them out
const currentTreeItems = currentTree.data.tree.filter(item => item.type !== "tree");
currentTreeItems.forEach(item => {
if (item.path === newPath) {
throw new Error("File with the path already exists");
}
});
const updatedTree = await this.ocktokit.git.createTree({
owner,
repo,
tree: currentTreeItems.map(item => ({
path: item.path === oldPath ? newPath : item.path,
mode: item.mode as "100644" | "100755" | "040000" | "160000" | "120000",
type: item.type as "blob" | "tree" | "commit",
sha: item.sha
}))
});
const newCommit = await this.ocktokit.git.createCommit({
owner,
repo,
message,
parents: [currentRef.data.object.sha],
tree: updatedTree.data.sha
});
const updatedRef = await this.ocktokit.git.updateRef({
owner,
repo,
ref,
sha: newCommit.data.sha
});
return {
status: updatedRef.status,
data: GitHubClient.toGitHubCommit(newCommit.data)
};
}
public async deleteFileAsync(file: IGitHubFile, message: string): Promise<IGitHubResponse<IGitHubCommit>> {
const response = await this.ocktokit.repos.deleteFile({
owner: file.repo.owner.login,
repo: file.repo.name,
path: file.path,
message,
sha: file.sha,
branch: file.branch.name
});
let data: IGitHubCommit;
if (response.data) {
data = GitHubClient.toGitHubCommit(response.data.commit);
}
return { status: response.status, data };
}
private initOctokit(token: string) {
this.ocktokit = new Octokit({
auth: token,
baseUrl: GitHubClient.gitHubApiEndpoint
});
this.ocktokit.hook.error("request", error => {
this.errorCallback(error);
throw error;
});
}
private static getDisableCacheHeaders(): RequestHeaders {
return {
"If-None-Match": ""
};
}
private static toGitHubRepo(element: IGitHubRepo): IGitHubRepo {
return {
name: element.name,
owner: {
login: element.owner.login
},
private: element.private
};
}
private static toGitHubBranch(element: IGitHubBranch): IGitHubBranch {
return {
name: element.name
};
}
private static toGitHubCommit(element: IGitHubCommit): IGitHubCommit {
return {
sha: element.sha,
message: element.message,
committer: {
date: element.committer.date
}
};
}
private static toGitHubFile(element: IGitHubFile, repo: IGitHubRepo, branch: IGitHubBranch): IGitHubFile {
return {
type: element.type,
encoding: element.encoding,
size: element.size,
name: element.name,
path: element.path,
content: element.content,
sha: element.sha,
url: element.url,
html_url: element.html_url,
repo,
branch
};
}
private static isSamplesCall(owner: string, repo: string, branch?: string): boolean {
return owner === "Azure-Samples" && repo === "cosmos-notebooks" && (!branch || branch === "master");
}
}

View File

@@ -0,0 +1,30 @@
export interface IGitHubConnectorParams {
state: string;
code: string;
}
export const GitHubConnectorMsgType = "GitHubConnectorMsgType";
export class GitHubConnector {
public start(params: URLSearchParams, window: Window & typeof globalThis) {
window.postMessage(
{
type: GitHubConnectorMsgType,
data: {
state: params.get("state"),
code: params.get("code")
} as IGitHubConnectorParams
},
window.location.origin
);
}
}
var connector = new GitHubConnector();
window.addEventListener("load", () => {
const openerWindow = window.opener;
if (openerWindow) {
connector.start(new URLSearchParams(document.location.search), openerWindow);
window.close();
}
});

View File

@@ -0,0 +1,312 @@
import { IContent } from "@nteract/core";
import { fixture } from "@nteract/fixtures";
import { HttpStatusCodes } from "../Common/Constants";
import { GitHubClient, IGitHubCommit, IGitHubFile } from "./GitHubClient";
import { GitHubContentProvider } from "./GitHubContentProvider";
const gitHubClient = new GitHubClient("token", () => {});
const gitHubContentProvider = new GitHubContentProvider({
gitHubClient,
promptForCommitMsg: () => Promise.resolve("commit msg")
});
const sampleGitHubUri = `https://github.com/login/repo/blob/branch/dir/name.ipynb`;
const sampleFile: IGitHubFile = {
type: "file",
encoding: "encoding",
size: 0,
name: "name.ipynb",
path: "dir/name.ipynb",
content: btoa(fixture),
sha: "sha",
url: "url",
html_url: sampleGitHubUri,
repo: {
owner: {
login: "login"
},
name: "repo",
private: false
},
branch: {
name: "branch"
}
};
const sampleNotebookModel: IContent<"notebook"> = {
name: sampleFile.name,
path: sampleFile.html_url,
type: "notebook",
writable: true,
created: "",
last_modified: "date",
mimetype: "application/x-ipynb+json",
content: sampleFile.content ? JSON.parse(atob(sampleFile.content)) : null,
format: "json"
};
const gitHubCommit: IGitHubCommit = {
sha: "sha",
message: "message",
committer: {
date: "date"
}
};
describe("GitHubContentProvider remove", () => {
it("errors on invalid path", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync");
const response = await gitHubContentProvider.remove(null, "invalid path").toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
expect(gitHubClient.getContentsAsync).not.toBeCalled();
});
it("errors on failed read", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.remove(null, sampleGitHubUri).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
});
it("errors on failed delete", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "deleteFileAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.remove(null, sampleGitHubUri).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(gitHubClient.deleteFileAsync).toBeCalled();
});
it("removes notebook", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "deleteFileAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit })
);
const response = await gitHubContentProvider.remove(null, sampleGitHubUri).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(HttpStatusCodes.NoContent);
expect(gitHubClient.deleteFileAsync).toBeCalled();
expect(response.response).toBeUndefined();
});
});
describe("GitHubContentProvider get", () => {
it("errors on invalid path", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync");
const response = await gitHubContentProvider.get(null, "invalid path", null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
expect(gitHubClient.getContentsAsync).not.toBeCalled();
});
it("errors on failed read", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.get(null, sampleGitHubUri, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
});
it("reads notebook", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "getCommitsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: [gitHubCommit] })
);
const response = await gitHubContentProvider.get(null, sampleGitHubUri, {}).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(response.response).toEqual(sampleNotebookModel);
});
});
describe("GitHubContentProvider update", () => {
it("errors on invalid path", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync");
const response = await gitHubContentProvider.update(null, "invalid path", null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
expect(gitHubClient.getContentsAsync).not.toBeCalled();
});
it("errors on failed read", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.update(null, sampleGitHubUri, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
});
it("errors on failed rename", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "renameFileAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.update(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(gitHubClient.renameFileAsync).toBeCalled();
});
it("updates notebook", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "renameFileAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit })
);
spyOn(GitHubClient.prototype, "getCommitsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: [gitHubCommit] })
);
const response = await gitHubContentProvider.update(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(gitHubClient.renameFileAsync).toBeCalled();
expect(response.response.type).toEqual(sampleNotebookModel.type);
expect(response.response.name).toEqual(sampleNotebookModel.name);
expect(response.response.path).toEqual(sampleNotebookModel.path);
expect(response.response.content).toBeUndefined();
});
});
describe("GitHubContentProvider create", () => {
it("errors on invalid path", async () => {
spyOn(GitHubClient.prototype, "createOrUpdateFileAsync");
const response = await gitHubContentProvider.create(null, "invalid path", sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
expect(gitHubClient.createOrUpdateFileAsync).not.toBeCalled();
});
it("errors on failed create", async () => {
spyOn(GitHubClient.prototype, "createOrUpdateFileAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.create(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.createOrUpdateFileAsync).toBeCalled();
});
it("creates notebook", async () => {
spyOn(GitHubClient.prototype, "createOrUpdateFileAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.Created, data: gitHubCommit })
);
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
const response = await gitHubContentProvider.create(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(HttpStatusCodes.Created);
expect(gitHubClient.createOrUpdateFileAsync).toBeCalled();
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(response.response.type).toEqual(sampleNotebookModel.type);
expect(response.response.name).toEqual(sampleNotebookModel.name);
expect(response.response.path).toEqual(sampleNotebookModel.path);
expect(response.response.content).toBeUndefined();
});
});
describe("GitHubContentProvider save", () => {
it("errors on invalid path", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync");
const response = await gitHubContentProvider.save(null, "invalid path", null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
expect(gitHubClient.getContentsAsync).not.toBeCalled();
});
it("errors on failed read", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.save(null, sampleGitHubUri, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
});
it("errors on failed update", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "createOrUpdateFileAsync").and.returnValue(Promise.resolve({ status: 888 }));
const response = await gitHubContentProvider.save(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(888);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(gitHubClient.createOrUpdateFileAsync).toBeCalled();
});
it("saves notebook", async () => {
spyOn(GitHubClient.prototype, "getContentsAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: sampleFile })
);
spyOn(GitHubClient.prototype, "createOrUpdateFileAsync").and.returnValue(
Promise.resolve({ status: HttpStatusCodes.OK, data: gitHubCommit })
);
const response = await gitHubContentProvider.save(null, sampleGitHubUri, sampleNotebookModel).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(gitHubClient.getContentsAsync).toBeCalled();
expect(gitHubClient.createOrUpdateFileAsync).toBeCalled();
expect(response.response.type).toEqual(sampleNotebookModel.type);
expect(response.response.name).toEqual(sampleNotebookModel.name);
expect(response.response.path).toEqual(sampleNotebookModel.path);
expect(response.response.content).toBeUndefined();
});
});
describe("GitHubContentProvider listCheckpoints", () => {
it("errors for everything", async () => {
const response = await gitHubContentProvider.listCheckpoints(null, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
});
});
describe("GitHubContentProvider createCheckpoint", () => {
it("errors for everything", async () => {
const response = await gitHubContentProvider.createCheckpoint(null, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
});
});
describe("GitHubContentProvider deleteCheckpoint", () => {
it("errors for everything", async () => {
const response = await gitHubContentProvider.deleteCheckpoint(null, null, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
});
});
describe("GitHubContentProvider restoreFromCheckpoint", () => {
it("errors for everything", async () => {
const response = await gitHubContentProvider.restoreFromCheckpoint(null, null, null).toPromise();
expect(response).toBeDefined();
expect(response.status).toBe(GitHubContentProvider.SelfErrorCode);
});
});

View File

@@ -0,0 +1,406 @@
import { Notebook, stringifyNotebook, makeNotebookRecord, toJS } from "@nteract/commutable";
import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core";
import { from, Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import { HttpStatusCodes } from "../Common/Constants";
import { Logger } from "../Common/Logger";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit } from "./GitHubClient";
import { GitHubUtils } from "../Utils/GitHubUtils";
export interface GitHubContentProviderParams {
gitHubClient: GitHubClient;
promptForCommitMsg: (title: string, primaryButtonLabel: string) => Promise<string>;
}
class GitHubContentProviderError extends Error {
constructor(error: string, public errno: number = GitHubContentProvider.SelfErrorCode) {
super(error);
}
}
// Provides 'contents' API for GitHub
// http://jupyter-api.surge.sh/#!/contents
export class GitHubContentProvider implements IContentProvider {
public static readonly SelfErrorCode = 555;
constructor(private params: GitHubContentProviderParams) {}
public remove(_: ServerConfig, uri: string): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Delete", "Delete");
const response = await this.params.gitHubClient.deleteFileAsync(content.data as IGitHubFile, commitMsg);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to delete", response.status);
}
return this.createSuccessAjaxResponse(HttpStatusCodes.NoContent, undefined);
} catch (error) {
Logger.logError(error, "GitHubContentProvider/remove", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public get(_: ServerConfig, uri: string, params: Partial<IGetParams>): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
if (content.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", content.status);
}
const gitHubInfo = GitHubUtils.fromGitHubUri(uri);
const commitResponse = await this.params.gitHubClient.getCommitsAsync(
gitHubInfo.owner,
gitHubInfo.repo,
gitHubInfo.branch,
gitHubInfo.path,
1,
1
);
if (commitResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get commit", commitResponse.status);
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,
this.createContentModel(uri, content.data, commitResponse.data[0], params)
);
} catch (error) {
Logger.logError(error, "GitHubContentProvider/get", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public update<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>>
): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const gitHubFile = content.data as IGitHubFile;
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Rename", "Rename");
const newUri = model.path;
const response = await this.params.gitHubClient.renameFileAsync(
gitHubFile.repo.owner.login,
gitHubFile.repo.name,
gitHubFile.branch.name,
commitMsg,
gitHubFile.path,
GitHubUtils.fromGitHubUri(newUri).path
);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to rename", response.status);
}
const updatedContentResponse = await this.getContent(model.path);
if (updatedContentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content after renaming", updatedContentResponse.status);
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,
this.createContentModel(newUri, updatedContentResponse.data, response.data, { content: 0 })
);
} catch (error) {
Logger.logError(error, "GitHubContentProvider/update", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public create<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>> & { type: FT }
): Observable<AjaxResponse> {
return from(
this.params.promptForCommitMsg("Create New Notebook", "Create").then(async (commitMsg: string) => {
try {
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
if (model.type !== "notebook") {
throw new GitHubContentProviderError("Unsupported content type");
}
const gitHubInfo = GitHubUtils.fromGitHubUri(uri);
if (!gitHubInfo) {
throw new GitHubContentProviderError(`Failed to parse ${uri}`);
}
const content = btoa(stringifyNotebook(toJS(makeNotebookRecord())));
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false
};
const name = `Untitled-${new Date().toLocaleString("default", options)}.ipynb`;
let path = name;
if (gitHubInfo.path) {
path = `${gitHubInfo.path}/${name}`;
}
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
gitHubInfo.owner,
gitHubInfo.repo,
gitHubInfo.branch,
path,
commitMsg,
content
);
if (response.status !== HttpStatusCodes.Created) {
throw new GitHubContentProviderError("Failed to create", response.status);
}
const newUri = GitHubUtils.toGitHubUriForRepoAndBranch(
gitHubInfo.owner,
gitHubInfo.repo,
gitHubInfo.branch,
path
);
const newContentResponse = await this.getContent(newUri);
if (newContentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content after creating", newContentResponse.status);
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.Created,
this.createContentModel(newUri, newContentResponse.data, response.data, { content: 0 })
);
} catch (error) {
Logger.logError(error, "GitHubContentProvider/create", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public save<FT extends FileType>(
_: ServerConfig,
uri: string,
model: Partial<IContent<FT>>
): Observable<AjaxResponse> {
return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try {
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
let updatedContent: string;
if (model.type === "notebook") {
updatedContent = btoa(stringifyNotebook(model.content as Notebook));
} else if (model.type === "file") {
updatedContent = model.content as string;
if (model.format !== "base64") {
updatedContent = btoa(updatedContent);
}
} else {
throw new GitHubContentProviderError("Unsupported content type");
}
const gitHubFile = content.data as IGitHubFile;
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
gitHubFile.repo.owner.login,
gitHubFile.repo.name,
gitHubFile.branch.name,
gitHubFile.path,
commitMsg,
updatedContent,
gitHubFile.sha
);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to update", response.status);
}
const savedContentResponse = await this.getContent(uri);
if (savedContentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content after updating", savedContentResponse.status);
}
return this.createSuccessAjaxResponse(
HttpStatusCodes.OK,
this.createContentModel(uri, savedContentResponse.data, response.data, { content: 0 })
);
} catch (error) {
Logger.logError(error, "GitHubContentProvider/update", error.errno);
return this.createErrorAjaxResponse(error);
}
})
);
}
public listCheckpoints(_: ServerConfig, path: string): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error, "GitHubContentProvider/listCheckpoints", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public createCheckpoint(_: ServerConfig, path: string): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error, "GitHubContentProvider/createCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public deleteCheckpoint(_: ServerConfig, path: string, checkpointID: string): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error, "GitHubContentProvider/deleteCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
public restoreFromCheckpoint(_: ServerConfig, path: string, checkpointID: string): Observable<AjaxResponse> {
const error = new GitHubContentProviderError("Not implemented");
Logger.logError(error, "GitHubContentProvider/restoreFromCheckpoint", error.errno);
return of(this.createErrorAjaxResponse(error));
}
private async validateContentAndGetCommitMsg(
content: IGitHubResponse<IGitHubFile | IGitHubFile[]>,
promptTitle: string,
promptPrimaryButtonLabel: string
): Promise<string> {
if (content.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", content.status);
}
if (Array.isArray(content.data)) {
throw new GitHubContentProviderError("Operation not supported for collections");
}
const commitMsg = await this.params.promptForCommitMsg(promptTitle, promptPrimaryButtonLabel);
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
return commitMsg;
}
private getContent(uri: string): Promise<IGitHubResponse<IGitHubFile | IGitHubFile[]>> {
const gitHubInfo = GitHubUtils.fromGitHubUri(uri);
if (gitHubInfo) {
const { owner, repo, branch, path } = gitHubInfo;
return this.params.gitHubClient.getContentsAsync(owner, repo, branch, path);
}
return Promise.resolve({ status: GitHubContentProvider.SelfErrorCode, data: undefined });
}
private createContentModel(
uri: string,
content: IGitHubFile | IGitHubFile[],
commit: IGitHubCommit,
params: Partial<IGetParams>
): IContent<FileType> {
if (Array.isArray(content)) {
return this.createDirectoryModel(uri, content, commit);
}
if (content.type !== "file") {
return this.createDirectoryModel(uri, undefined, commit);
}
if (NotebookUtil.isNotebookFile(uri)) {
return this.createNotebookModel(content, commit, params);
}
return this.createFileModel(content, commit, params);
}
private createDirectoryModel(
uri: string,
gitHubFiles: IGitHubFile[] | undefined,
commit: IGitHubCommit
): IContent<"directory"> {
return {
name: NotebookUtil.getContentName(uri),
path: uri,
type: "directory",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: commit.committer.date,
mimetype: undefined,
content: gitHubFiles?.map(
(file: IGitHubFile) =>
this.createContentModel(GitHubUtils.toGitHubUriForFile(file), file, commit, {
content: 0
}) as IEmptyContent<FileType>
),
format: "json"
};
}
private createNotebookModel(
gitHubFile: IGitHubFile,
commit: IGitHubCommit,
params: Partial<IGetParams>
): IContent<"notebook"> {
const content: Notebook =
gitHubFile.content && params.content !== 0 ? JSON.parse(atob(gitHubFile.content)) : undefined;
return {
name: gitHubFile.name,
path: GitHubUtils.toGitHubUriForFile(gitHubFile),
type: "notebook",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: commit.committer.date,
mimetype: content ? "application/x-ipynb+json" : undefined,
content,
format: content ? "json" : undefined
};
}
private createFileModel(
gitHubFile: IGitHubFile,
commit: IGitHubCommit,
params: Partial<IGetParams>
): IContent<"file"> {
const content: string = gitHubFile.content && params.content !== 0 ? atob(gitHubFile.content) : undefined;
return {
name: gitHubFile.name,
path: GitHubUtils.toGitHubUriForFile(gitHubFile),
type: "file",
writable: true, // TODO: tamitta: we don't know this info here
created: "", // TODO: tamitta: we don't know this info here
last_modified: commit.committer.date,
mimetype: content ? "text/plain" : undefined,
content,
format: content ? "text" : undefined
};
}
private createSuccessAjaxResponse(status: number, content: IContent<FileType>): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status,
response: content ? content : undefined,
responseText: content ? JSON.stringify(content) : undefined,
responseType: "json"
};
}
private createErrorAjaxResponse(error: GitHubContentProviderError): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status: error.errno,
response: error,
responseText: JSON.stringify(error),
responseType: "json"
};
}
}

View File

@@ -0,0 +1,162 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import * as ViewModels from "../Contracts/ViewModels";
import { JunoClient } from "../Juno/JunoClient";
import { GitHubConnector, IGitHubConnectorParams } from "./GitHubConnector";
import { GitHubOAuthService } from "./GitHubOAuthService";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
const sampleDatabaseAccount: ViewModels.DatabaseAccount = {
id: "id",
name: "name",
location: "location",
type: "type",
kind: "kind",
tags: [],
properties: {
documentEndpoint: "documentEndpoint",
gremlinEndpoint: "gremlinEndpoint",
tableEndpoint: "tableEndpoint",
cassandraEndpoint: "cassandraEndpoint"
}
};
describe("GitHubOAuthService", () => {
let junoClient: JunoClient;
let gitHubOAuthService: GitHubOAuthService;
let originalDataExplorer: ViewModels.Explorer;
beforeEach(() => {
junoClient = new JunoClient(ko.observable<ViewModels.DatabaseAccount>(sampleDatabaseAccount));
gitHubOAuthService = new GitHubOAuthService(junoClient);
originalDataExplorer = window.dataExplorer;
window.dataExplorer = {
...originalDataExplorer,
gitHubOAuthService,
logConsoleData: (data): void =>
data.type === ConsoleDataType.Error ? console.error(data.message) : console.log(data.message)
} as ViewModels.Explorer;
});
afterEach(() => {
jest.resetAllMocks();
window.dataExplorer = originalDataExplorer;
originalDataExplorer = undefined;
gitHubOAuthService = undefined;
junoClient = undefined;
});
it("logout deletes app authorization and resets token", async () => {
const deleteAppAuthorizationCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NoContent });
junoClient.deleteAppAuthorization = deleteAppAuthorizationCallback;
await gitHubOAuthService.logout();
expect(deleteAppAuthorizationCallback).toBeCalled();
expect(gitHubOAuthService.getTokenObservable()()).toBeUndefined();
});
it("resetToken resets token", () => {
gitHubOAuthService.resetToken();
expect(gitHubOAuthService.getTokenObservable()()).toBeUndefined();
});
it("startOAuth resets OAuth state", () => {
let url: string;
const windowOpenCallback = jest.fn().mockImplementation((value: string) => {
url = value;
});
window.open = windowOpenCallback;
gitHubOAuthService.startOAuth("scope");
expect(windowOpenCallback).toBeCalled();
const initialParams = new URLSearchParams(new URL(url).search);
expect(initialParams.get("state")).toBeDefined();
gitHubOAuthService.startOAuth("another scope");
expect(windowOpenCallback).toBeCalled();
const newParams = new URLSearchParams(new URL(url).search);
expect(newParams.get("state")).toBeDefined();
expect(newParams.get("state")).not.toEqual(initialParams.get("state"));
});
it("finishOAuth is called whenever GitHubConnector is started", async () => {
const finishOAuthCallback = jest.fn().mockImplementation();
gitHubOAuthService.finishOAuth = finishOAuthCallback;
const params: IGitHubConnectorParams = {
state: "state",
code: "code"
};
const searchParams = new URLSearchParams({ ...params });
const gitHubConnector = new GitHubConnector();
gitHubConnector.start(searchParams, window);
// GitHubConnector uses Window.postMessage and there's no good way to know when the message has received
await new Promise(resolve => setTimeout(resolve, 100));
expect(finishOAuthCallback).toBeCalledWith(params);
});
it("finishOAuth updates token", async () => {
const data = { key: "value" };
const getGitHubTokenCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.OK, data });
junoClient.getGitHubToken = getGitHubTokenCallback;
const initialToken = gitHubOAuthService.getTokenObservable()();
const state = gitHubOAuthService.startOAuth("scope");
const params: IGitHubConnectorParams = {
state,
code: "code"
};
await gitHubOAuthService.finishOAuth(params);
const updatedToken = gitHubOAuthService.getTokenObservable()();
expect(getGitHubTokenCallback).toBeCalledWith("code");
expect(initialToken).not.toEqual(updatedToken);
});
it("finishOAuth updates token to error if state doesn't match", async () => {
gitHubOAuthService.startOAuth("scope");
const params: IGitHubConnectorParams = {
state: "state",
code: "code"
};
await gitHubOAuthService.finishOAuth(params);
expect(gitHubOAuthService.getTokenObservable()().error).toBeDefined();
});
it("finishOAuth updates token to error if unable to fetch token", async () => {
const getGitHubTokenCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NotFound });
junoClient.getGitHubToken = getGitHubTokenCallback;
const state = gitHubOAuthService.startOAuth("scope");
const params: IGitHubConnectorParams = {
state,
code: "code"
};
await gitHubOAuthService.finishOAuth(params);
expect(getGitHubTokenCallback).toBeCalledWith("code");
expect(gitHubOAuthService.getTokenObservable()().error).toBeDefined();
});
it("isLoggedIn returns false if resetToken is called", () => {
gitHubOAuthService.resetToken();
expect(gitHubOAuthService.isLoggedIn()).toBeFalsy();
});
it("isLoggedIn returns false if logout is called", async () => {
const deleteAppAuthorizationCallback = jest.fn().mockReturnValue({ status: HttpStatusCodes.NoContent });
junoClient.deleteAppAuthorization = deleteAppAuthorizationCallback;
await gitHubOAuthService.logout();
expect(gitHubOAuthService.isLoggedIn()).toBeFalsy();
});
});

View File

@@ -0,0 +1,112 @@
import ko from "knockout";
import { HttpStatusCodes } from "../Common/Constants";
import { Logger } from "../Common/Logger";
import { config } from "../Config";
import { ConsoleDataType } from "../Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import { JunoClient } from "../Juno/JunoClient";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
import { NotificationConsoleUtils } from "../Utils/NotificationConsoleUtils";
import { GitHubConnectorMsgType, IGitHubConnectorParams } from "./GitHubConnector";
window.addEventListener("message", (event: MessageEvent) => {
if (isInvalidParentFrameOrigin(event)) {
return;
}
const msg = event.data;
if (msg.type === GitHubConnectorMsgType) {
const params = msg.data as IGitHubConnectorParams;
window.dataExplorer.gitHubOAuthService.finishOAuth(params);
}
});
export interface IGitHubOAuthToken {
// API properties
access_token?: string;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
}
export class GitHubOAuthService {
private static readonly OAuthEndpoint = "https://github.com/login/oauth/authorize";
private state: string;
private token: ko.Observable<IGitHubOAuthToken>;
constructor(private junoClient: JunoClient) {
this.token = ko.observable<IGitHubOAuthToken>();
}
public startOAuth(scope: string): string {
const params = {
scope,
client_id: config.GITHUB_CLIENT_ID,
redirect_uri: new URL("./connectToGitHub.html", window.location.href).href,
state: this.resetState()
};
window.open(`${GitHubOAuthService.OAuthEndpoint}?${new URLSearchParams(params).toString()}`);
return params.state;
}
public async finishOAuth(params: IGitHubConnectorParams) {
try {
this.validateState(params.state);
const response = await this.junoClient.getGitHubToken(params.code);
if (response.status === HttpStatusCodes.OK && !response.data.error) {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully connected to GitHub");
this.token(response.data);
} else {
let errorMsg = response.data.error;
if (response.data.error_description) {
errorMsg = `${errorMsg}: ${response.data.error_description}`;
}
throw new Error(errorMsg);
}
} catch (error) {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, `Failed to connect to GitHub: ${error}`);
this.token({ error });
}
}
public getTokenObservable(): ko.Observable<IGitHubOAuthToken> {
return this.token;
}
public async logout() {
try {
const response = await this.junoClient.deleteAppAuthorization(this.token()?.access_token);
if (response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status}: ${response.data} when deleting app authorization`);
}
this.resetToken();
} catch (error) {
const message = `Failed to delete app authorization: ${error}`;
Logger.logError(message, "GitHubOAuthService/logout");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
}
public isLoggedIn(): boolean {
return !!this.token()?.access_token;
}
private resetState(): string {
this.state = Math.floor(Math.random() * Math.floor(Number.MAX_SAFE_INTEGER)).toString();
return this.state;
}
public resetToken() {
this.token(undefined);
}
private validateState(state: string) {
if (state !== this.state) {
throw new Error("State didn't match. Possibility of cross-site request forgery attack.");
}
}
}