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] add getInstallationUrl method #542

Merged
merged 16 commits into from
Jun 6, 2024
Merged
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [`app.getInstallationOctokit`](#appgetinstallationoctokit)
- [`app.eachInstallation`](#appeachinstallation)
- [`app.eachRepository`](#appeachrepository)
- [`app.getInstallationUrl`](#appgetinstallationurl)
- [`app.webhooks`](#appwebhooks)
- [`app.oauth`](#appoauth)
- [Middlewares](#middlewares)
Expand Down Expand Up @@ -299,6 +300,22 @@ for await (const { octokit, repository } of app.eachRepository.iterator({ instal
await app.eachRepository({ installationId }, ({ octokit, repository }) => /* ... */)
```

### `app.getInstallationUrl`

```js
const installationUrl = await app.getInstallationUrl();
return res.redirect(installationUrl);
```

Optionally pass the ID of a GitHub organization or user to request installation on that specific target.

If the user will be sent to a redirect URL after installation (such as if you request user authorization during installation), you can also supply a `state` string that will be included in the query of the post-install redirect.

```js
const installationUrl = await app.getInstallationUrl({ state, target_id });
return res.redirect(installationUrl);
```

### `app.webhooks`

An [`@octokit/webhooks` instance](https://github.com/octokit/webhooks.js/#readme)
Expand Down
39 changes: 39 additions & 0 deletions src/get-installation-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { App } from "./index.js";
import type { GetInstallationUrlOptions } from "./types.js";

export function getInstallationUrlFactory(app: App) {
let installationUrlBasePromise: Promise<string> | undefined;

return async function getInstallationUrl(
options: GetInstallationUrlOptions = {},
) {
if (!installationUrlBasePromise) {
installationUrlBasePromise = getInstallationUrlBase(app);
}

const installationUrlBase = await installationUrlBasePromise;
const installationUrl = new URL(installationUrlBase);

if (options.target_id !== undefined) {
installationUrl.pathname += "/permissions";
installationUrl.searchParams.append(
"target_id",
options.target_id.toFixed(),
);
}

if (options.state !== undefined) {
installationUrl.searchParams.append("state", options.state);
}

return installationUrl.href;
};
}

async function getInstallationUrlBase(app: App) {
const { data: appInfo } = await app.octokit.request("GET /app");
if (!appInfo) {
throw new Error("[@octokit/app] unable to fetch metadata for app");
}
return `${appInfo.html_url}/installations/new`;
rpmccarter marked this conversation as resolved.
Show resolved Hide resolved
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
EachInstallationInterface,
EachRepositoryInterface,
GetInstallationOctokitInterface,
GetInstallationUrlInterface,
} from "./types.js";

// Export types required for the App class
Expand All @@ -18,13 +19,15 @@ export type {
EachInstallationInterface,
EachRepositoryInterface,
GetInstallationOctokitInterface,
GetInstallationUrlInterface,
} from "./types.js";

import { VERSION } from "./version.js";
import { webhooks } from "./webhooks.js";
import { eachInstallationFactory } from "./each-installation.js";
import { eachRepositoryFactory } from "./each-repository.js";
import { getInstallationOctokit } from "./get-installation-octokit.js";
import { getInstallationUrlFactory } from "./get-installation-url.js";

type Constructor<T> = new (...args: any[]) => T;

Expand Down Expand Up @@ -70,6 +73,7 @@ export class App<TOptions extends Options = Options> {
>;
eachInstallation: EachInstallationInterface<OctokitType<TOptions>>;
eachRepository: EachRepositoryInterface<OctokitType<TOptions>>;
getInstallationUrl: GetInstallationUrlInterface;
log: {
debug: (message: string, additionalInfo?: object) => void;
info: (message: string, additionalInfo?: object) => void;
Expand Down Expand Up @@ -150,6 +154,7 @@ export class App<TOptions extends Options = Options> {
this.eachRepository = eachRepositoryFactory(
this,
) as EachRepositoryInterface<OctokitType<TOptions>>;
this.getInstallationUrl = getInstallationUrlFactory(this);
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,12 @@ export interface EachRepositoryInterface<O> {
export interface GetInstallationOctokitInterface<O> {
(installationId: number): Promise<O>;
}

export interface GetInstallationUrlOptions {
state?: string;
target_id?: number;
}

export interface GetInstallationUrlInterface {
(options?: GetInstallationUrlOptions): Promise<string>;
}
166 changes: 166 additions & 0 deletions test/get-installation-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Octokit } from "@octokit/core";
import fetchMock from "fetch-mock";
import MockDate from "mockdate";

const APP_ID = 1;
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const CLIENT_ID = "0123";
const CLIENT_SECRET = "0123secret";
const WEBHOOK_SECRET = "secret";

import { App } from "../src/index.ts";

describe("app.getInstallationUrl", () => {
let app: InstanceType<typeof App>;
let mock: typeof fetchMock;

beforeEach(() => {
MockDate.set(0);
mock = fetchMock.sandbox();

app = new App({
appId: APP_ID,
privateKey: PRIVATE_KEY,
oauth: {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
},
webhooks: {
secret: WEBHOOK_SECRET,
},
Octokit: Octokit.defaults({
request: {
fetch: mock,
},
}),
});
});

test("throws when response is null", async () => {
mock.getOnce("path:/app", {
body: "null",
headers: { "Content-Type": "application/json" },
});

await expect(app.getInstallationUrl()).rejects.toThrow(
"[@octokit/app] unable to fetch metadata for app",
);

expect(mock.done()).toBe(true);
});

test("returns correct url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});

const url = await app.getInstallationUrl();

expect(url).toEqual("https://github.com/apps/octokit/installations/new");
expect(mock.done()).toBe(true);
});

test("caches url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});

const urls = await Promise.all([
app.getInstallationUrl(),
app.getInstallationUrl(),
app.getInstallationUrl(),
]);

expect(urls).toEqual(
new Array(3).fill("https://github.com/apps/octokit/installations/new"),
);
expect(mock.done()).toBe(true);
});

test("does not cache state", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123";

const urlWithoutState = await app.getInstallationUrl();
const urlWithState = await app.getInstallationUrl({ state });

expect(urlWithoutState).toEqual(
"https://github.com/apps/octokit/installations/new",
);
expect(urlWithState).toEqual(
`https://github.com/apps/octokit/installations/new?state=${state}`,
);
expect(mock.done()).toBe(true);
});

test("adds the url-encoded state string to the url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123%/{";

const url = await app.getInstallationUrl({ state });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new?state=${encodeURIComponent(state)}`,
);
expect(mock.done()).toBe(true);
});

test("appends /permissions to the url when target_id present", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const target_id = 456;

const url = await app.getInstallationUrl({ target_id });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}`,
);
expect(mock.done()).toBe(true);
});

test("adds both state and target_id to the url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123";
const target_id = 456;

const url = await app.getInstallationUrl({ state, target_id });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}&state=${state}`,
);
expect(mock.done()).toBe(true);
});
});