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

ARC 2495 [BE] - Deferred Installation #2507

Merged
merged 16 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class InvalidTokenError extends RestApiError {

export class InsufficientPermissionError extends RestApiError {
constructor(msg: string) {
super(401, "INSUFFICIENT_PERMISSION", msg);
super(403, "INSUFFICIENT_PERMISSION", msg);
}
}

14 changes: 14 additions & 0 deletions src/rest-interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export type UsersGetAuthenticatedResponse = {
login: string;
};

export type DeferralParsedRequest = {
orgName: string;
jiraHost: string;
};

export type GetDeferredInstallationUrl = {
deferredInstallUrl: string;
};

export type OrgOwnershipResponse = {
orgName: string;
}

export type GetGitHubAppsUrlResponse = {
appInstallationUrl: string;
}
Expand Down Expand Up @@ -62,6 +75,7 @@ export type ErrorCode =
| "IP_BLOCKED"
| "SSO_LOGIN"
| "RESOURCE_NOT_FOUND"
| "JIRAHOST_MISMATCH"
| "UNKNOWN";

export type Account = {
Expand Down
45 changes: 2 additions & 43 deletions src/rest/middleware/jira-admin/jira-admin-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import supertest from "supertest";
import { Installation } from "models/installation";
import { booleanFlag, BooleanFlags } from "config/feature-flags";
import { when } from "jest-when";
import { JiraClient } from "models/jira-client";
import { getFrontendApp } from "~/src/app";

jest.mock("config/feature-flags");
Expand All @@ -15,7 +14,6 @@ const testSharedSecret = "test-secret";
describe("Jira Admin Check", () => {

let app: Application;
let installation: Installation;

const USER_ACC_ID = "12345";

Expand All @@ -25,54 +23,15 @@ describe("Jira Admin Check", () => {

app = getFrontendApp();

installation = await Installation.install({
await Installation.install({
clientKey: "jira-client-key",
host: jiraHost,
sharedSecret: testSharedSecret
});

});

const mockPermission = (permissions: string[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
when(JiraClient.getNewClient).calledWith(expect.anything(), expect.anything())
.mockImplementation((reqInst: Installation): Promise<JiraClient> => {
if (reqInst.id === installation.id) {
return Promise.resolve({
checkAdminPermissions: jest.fn((userAccountId) => {
if (userAccountId === USER_ACC_ID) {
return { data: { globalPermissions: permissions } };
} else {
return { data: { globalPermissions: ["ADMINISTER", "OTHER_ROLE"] } };
}
})
}) as unknown as Promise<JiraClient>;
} else {
throw new Error(`Wrong installation ${reqInst.toString()}`);
}
});

};

it("should fail if is not admin", async () => {

mockPermission([ "OTHER_ROLE" ]);

const res = await sendRequestWithToken();

expect(res.status).toEqual(401);
expect(JSON.parse(res.text)).toEqual(expect.objectContaining({
errorCode: "INSUFFICIENT_PERMISSION",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
message: expect.stringContaining("Forbidden")
}));

});

it("should pass request if is admin", async () => {

mockPermission([ "ADMINISTER", "OTHER_ROLE" ]);

it("should pass request regardless of Jira permissions", async () => {
const res = await sendRequestWithToken();

expect(res.status).toEqual(200);
Expand Down
7 changes: 5 additions & 2 deletions src/rest/rest-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RestErrorHandler } from "./middleware/error";
import { JiraAdminEnforceMiddleware } from "./middleware/jira-admin/jira-admin-check";
import { AnalyticsProxyHandler } from "./routes/analytics-proxy";
import { SubscriptionsRouter } from "./routes/subscriptions";
import { DeferredRouter } from "./routes/deferred";

export const RestRouter = Router({ mergeParams: true });

Expand All @@ -30,15 +31,17 @@ subRouter.get("/github-callback", OAuthCallbackHandler);
subRouter.get("/github-installed", OrgsInstalledHandler);
subRouter.get("/github-requested", OrgsInstallRequestedHandler);

subRouter.use("/oauth", OAuthRouter);

subRouter.use("/deferred", DeferredRouter);

// TODO: what about Jira admin validation (a.k.a. authorization, we
// have done authentication only)?
subRouter.use(JwtHandler);
subRouter.use(JiraAdminEnforceMiddleware);

subRouter.post("/analytics-proxy", AnalyticsProxyHandler);

subRouter.use("/oauth", OAuthRouter);

subRouter.use("/installation", GitHubAppsRoute);

subRouter.use("/jira/cloudid", JiraCloudIDRouter);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import supertest from "supertest";
import { getFrontendApp } from "~/src/app";
import { Installation } from "models/installation";

const VALID_REQUEST_ID = "customized-uuid-customized-uuid";
const validData = {
gitHubInstallationId: 15,
jiraHost: "https://customJirahost.com",
installationIdPk: 0,
orgName: "custom-orgName"
};
jest.mock("services/subscription-deferred-install-service",
() => ({
extractSubscriptionDeferredInstallPayload: (id: string) => {
// Mocking the redis values
if (id === VALID_REQUEST_ID) {
return Promise.resolve(validData);
} else {
throw new Error("Empty request ID");
}
}
})
);

describe("rest deferred installation redirect route check", () => {
let app, installation;
const testSharedSecret = "test-secret";
beforeEach(async () => {
app = getFrontendApp();
installation = await Installation.install({
clientKey: "jira-client-key",
host: jiraHost,
sharedSecret: testSharedSecret
});
validData.installationIdPk = installation.id;
});

describe("cloud", () => {
it("should throw 401 error when no github token is passed", async () => {
const resp = await supertest(app)
.post(`/rest/app/cloud/deferred/connect/${VALID_REQUEST_ID}`);

expect(resp.status).toEqual(401);
});

it("should return 403 error for non-owners", async () => {
githubNock
.get("/user")
.reply(200, { login: "test-user" });
githubNock
.get(`/app/installations/${validData.gitHubInstallationId}`)
.reply(200, {
"id": 4,
"account": {
"login": "test-org-1",
"id": 11,
"type": "User",
"site_admin": false
},
"app_id": 111
});
githubNock
.get("/user/memberships/orgs/test-org-1")
.reply(200, {
role: "user"
});

const resp = await supertest(app)
.post(`/rest/app/cloud/deferred/connect/${VALID_REQUEST_ID}`)
.set("github-auth", "github-token");

expect(resp.status).toBe(403);
});

it("should return 200 for owners", async () => {
githubNock
.get("/user")
.reply(200, { login: "test-user" });
githubNock
.get(`/app/installations/${validData.gitHubInstallationId}`)
.reply(200, {
"id": 4,
"account": {
"login": "test-org-1",
"id": 11,
"type": "User",
"site_admin": false
},
"app_id": 111
});
githubNock
.get("/user/memberships/orgs/test-org-1")
.reply(200, {
role: "admin"
});

const resp = await supertest(app)
.post(`/rest/app/cloud/deferred/connect/${VALID_REQUEST_ID}`)
.set("github-auth", "github-token");

expect(resp.status).toBe(200);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response } from "express";
import { errorWrapper } from "../../helper";
import { extractSubscriptionDeferredInstallPayload } from "services/subscription-deferred-install-service";
import { InsufficientPermissionError, InvalidArgumentError } from "config/errors";
import { OrgOwnershipResponse } from "rest-interfaces";
import { verifyAdminPermsAndFinishInstallation } from "services/subscription-installation-service";
import { Installation } from "models/installation";

export const DeferredCheckOwnershipAndConnectRoute = errorWrapper("CheckOwnershipAndConnectRoute", async function DeferredCheckOwnershipAndConnectRoute(req: Request, res: Response<OrgOwnershipResponse>) {
const { githubToken } = res.locals;
const requestId = req.params.requestId;

if (!requestId) {
req.log.warn("Missing requestId in query");
throw new InvalidArgumentError("Missing requestId in query");
}

const { gitHubInstallationId, jiraHost, installationIdPk } = await extractSubscriptionDeferredInstallPayload(requestId);
if (!jiraHost) {
req.log.warn("Missing jiraHost from the requestId");
throw new InvalidArgumentError("Missing jiraHost from the requestId");
}
const installation = await Installation.findByPk(installationIdPk);
if (!installation) {
req.log.warn("Installation not found for this requestId");
throw new InvalidArgumentError("Installation not found for this requestId");
}
const isAdminResponse = await verifyAdminPermsAndFinishInstallation(
githubToken as string,
installation,
undefined,// TODO: Need to pass this value later for GHE apps
gitHubInstallationId,
true,
req.log
);
if (isAdminResponse.errorCode === "NOT_ADMIN") {
req.log.warn("User is not an admin of the org");
throw new InsufficientPermissionError(isAdminResponse.error || "Not admin of org");
} else {
krazziekay marked this conversation as resolved.
Show resolved Hide resolved
res.sendStatus(200);
krazziekay marked this conversation as resolved.
Show resolved Hide resolved
}
});

68 changes: 68 additions & 0 deletions src/rest/routes/deferred/deferred-installation-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import supertest from "supertest";
import { getFrontendApp } from "~/src/app";
import { Installation } from "models/installation";
import { encodeSymmetric } from "atlassian-jwt";
import { envVars } from "config/env";
import { GetDeferredInstallationUrl } from "rest-interfaces";

const CUSTOMIZED_UUID = "customized-uuid-customized-uuid";
jest.mock("uuid", () => ({ v4: () => CUSTOMIZED_UUID }));

describe("Checking the deferred installation url route", () => {
const testSharedSecret = "test-secret";
const getToken = ({
secret = testSharedSecret,
iss = "jira-client-key",
exp = Date.now() / 1000 + 10000,
qsh = "context-qsh" } = {}): string => encodeSymmetric({
qsh,
iss,
exp
}, secret);
let app;
beforeEach(async () => {
app = getFrontendApp();
await Installation.install({
clientKey: "jira-client-key",
host: jiraHost,
sharedSecret: testSharedSecret
});
});

describe("rest oauth callback", () => {
describe("cloud", () => {
it("should throw error when no gitHubInstallationId is passed", async () => {
const gitHubOrgName = "sampleOrgName";

const resp = await supertest(app)
.get(`/rest/app/cloud/deferred/installation-url?gitHubOrgName=${gitHubOrgName}`)
.set("authorization", `${getToken()}`);

expect(resp.status).toEqual(400);
});

it("should throw error when no gitHubOrgName is passed", async () => {
const gitHubInstallationId = 1234567890;

const resp = await supertest(app)
.get(`/rest/app/cloud/deferred/installation-url?gitHubInstallationId=${gitHubInstallationId}`)
.set("authorization", `${getToken()}`);

expect(resp.status).toEqual(400);
});

it("should return the deferred installation url", async () => {
const gitHubInstallationId = 1234567890;
const gitHubOrgName = "sampleOrgName";

const resp = await supertest(app)
.get(`/rest/app/cloud/deferred/installation-url?gitHubInstallationId=${gitHubInstallationId}&gitHubOrgName=${gitHubOrgName}`)
.set("authorization", `${getToken()}`);

expect(resp.status).toEqual(200);
expect(resp.body).toHaveProperty("deferredInstallUrl");
expect((resp.body as GetDeferredInstallationUrl).deferredInstallUrl).toBe(`${envVars.APP_URL}/spa/deferred?requestId=${CUSTOMIZED_UUID}`);
});
});
});
});
40 changes: 40 additions & 0 deletions src/rest/routes/deferred/deferred-installation-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Request, Response } from "express";
import { errorWrapper } from "../../helper";
import { GetDeferredInstallationUrl } from "rest-interfaces";
import { BaseLocals } from "..";
import {
registerSubscriptionDeferredInstallPayloadRequest,
SubscriptionDeferredInstallPayload
} from "services/subscription-deferred-install-service";
import { envVars } from "config/env";
import { InvalidArgumentError } from "config/errors";

export const DeferredInstallationUrlRoute = errorWrapper("GetDeferredInstallationUrl", async function DeferredInstallationUrlRoute(req: Request, res: Response<GetDeferredInstallationUrl, BaseLocals>) {
const { gitHubInstallationId, gitHubOrgName } = req.query;
const { installation } = res.locals;

if (!gitHubInstallationId) {
req.log.warn("Missing gitHubInstallationId in query");
throw new InvalidArgumentError("Missing gitHubInstallationId in query");
}

if (!gitHubOrgName) {
req.log.warn("Missing gitHubOrgName in query");
throw new InvalidArgumentError("Missing gitHubOrgName in query");
}

krazziekay marked this conversation as resolved.
Show resolved Hide resolved
const payload: SubscriptionDeferredInstallPayload = {
installationIdPk: installation.id,
jiraHost: installation.jiraHost,
gitHubInstallationId: parseInt(gitHubInstallationId.toString()),
orgName: gitHubOrgName.toString(),
gitHubServerAppIdPk: undefined // TODO: This only works for cloud, Add this value for GHE servers
};
const requestId = await registerSubscriptionDeferredInstallPayloadRequest(payload);
gxueatlassian marked this conversation as resolved.
Show resolved Hide resolved

// TODO: This only works for cloud, Add this value for GHE servers
const deferredInstallUrl = `${envVars.APP_URL}/spa/deferred?requestId=${requestId}`;
res.status(200).json({
deferredInstallUrl
});
});
Loading
Loading