Repository: anuraghazra/github-readme-stats
Pull request #2178 (2022-10-10 17:20:14)

feat: add PAT monitoring functions
This commit adds two monitoring functions that can be used to check
whether the PATs are functioning correctly:
 - status/up: Returns whether the PATs are rate limited.
 - status/pat-info: Returns information about the PATs.

@anuraghazra I created this PR for you to review the two PAT monitoring functions I added to the https://github.com/anuraghazra/github-readme-stats/tree/monitoring branch. We can, however, keep these cloud functions on the monitoring branch since they are not meant to be used by the users. 

The `up` function is meant to be run in dashboard software like [Grafana](https://grafana.com/) to see how many times in a given day the PATs were limited. The `pat-info` function can check if PATs are expired. I added caching headers to these functions to prevent abuse (i.e. 1 req per 10 min).

### See in action

**Up:** https://github-readme-stats-git-monitoring-github-readme-stats-team.vercel.app/api/status/up.
**PAT-info:** https://github-readme-stats-git-monitoring-github-readme-stats-team.vercel.app/api/status/pat-info.

### Dynamic badge

When you supply the `?type=shields` argument to the Up function, it will return a [dynamic shields.io JSON object](https://shields.io/endpoint). This object can be used to generate a [shields.io badge](https://shields.io/endpoint) that shows whether the Public Vercel instance is rate limited. We can change the badge label and message to our liking. You can also retrieve the result as a JSON object using the `type=json` argument.

<img alt="Custom badge" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgithub-readme-stats-git-monitoring-github-readme-stats-team.vercel.app%2Fapi%2Fstatus%2Fup%3Ftype%3Dshields">

```md
<img alt="Custom badge" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgithub-readme-stats-git-monitoring-github-readme-stats-team.vercel.app%2Fapi%2Fstatus%2Fup%3Ftype%3Dshields">
```

### Result

Not sure if the `pa-info` function is working correctly. It worked locally and for my own instance (see https://github-readme-stats-git-monitoring-rickstaa.vercel.app/api/status/pat-info), but for the public instance, it looks like we only have one valid PAT. I think the function puts `rate_limited` PATs in the expired field. I couldn't find information about the exact behaviour (i.e. error thrown) when a PAT token is limited since I didn't have a rate-limited token.

![image](https://user-images.githubusercontent.com/17570430/194921591-ad6c1817-d36e-4cba-82c6-442f2b1547b0.png)


vercel[bot]: **The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated |
| :--- | :----- | :------ | :------ |
| **github-readme-stats** | ✅ Ready ([Inspect](https://vercel.com/github-readme-stats-team/github-readme-stats/FT7oCxtWbNbU9D4dcGHcfYywWpkh)) | [Visit Preview](https://github-readme-stats-git-monitoring-github-readme-stats-team.vercel.app) | Oct 10, 2022 at 5:20PM (UTC) |


codecov[bot]: # [Codecov](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) Report
Base: **96.65**% // Head: **96.83**% // Increases project coverage by **`+0.17%`** :tada:
> Coverage data is based on head [(`a505538`)](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) compared to base [(`166972d`)](https://codecov.io/gh/anuraghazra/github-readme-stats/commit/166972d7e4f12ab17ee7aa2b2000d784444bd9fd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2178      +/-   ##
==========================================
+ Coverage   96.65%   96.83%   +0.17%     
==========================================
  Files          22       24       +2     
  Lines        3795     4008     +213     
  Branches      323      361      +38     
==========================================
+ Hits         3668     3881     +213     
  Misses        125      125              
  Partials        2        2              
```


| [Impacted Files](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) | Coverage Δ | |
|---|---|---|
| [api/status/pat-info.js](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra#diff-YXBpL3N0YXR1cy9wYXQtaW5mby5qcw==) | `100.00% <100.00%> (ø)` | |
| [api/status/up.js](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra#diff-YXBpL3N0YXR1cy91cC5qcw==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/anuraghazra/github-readme-stats/pull/2178?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra).

Zo-Bro-23: Any particular reason for not merging? This seems pretty useful to me for monitoring uptime.
rickstaa: > Any particular reason for not merging? This seems pretty useful to me for monitoring uptime.
> 
> Edit: Your domain works well, but wouldn't it be better if all features were included in `master`?

There is no reason other than this being an OS project and I want to give @anuraghazra all the time he needs to review it before we merge it. 👍
Zo-Bro-23: > > Any particular reason for not merging? This seems pretty useful to me for monitoring uptime.
> > Edit: Your domain works well, but wouldn't it be better if all features were included in `master`?
> 
> There is no reason other than this being an OS project and I want to give @anuraghazra all the time he needs to review it before we merge it. +1

Right. There's a small chance @anuraghazra might have forgotten about this though, since the last comment was on Oct 2022 😅
anuraghazra: Yeah I forgot, I did the review and made few changes. 

I'll take a final look today and merge it. 
anuraghazra: Tests are failing can you check?
rickstaa: > Tests are failing can you check?

Of course! Your new code stringifies the response where my tests expected a JS object. Fixed 👍🏻.
anuraghazra: shield badge is failing 🤔 
 ![image](https://user-images.githubusercontent.com/35374649/215262389-0e590ff4-d111-4e36-832c-358f9c1832aa.png)

rickstaa: > shield badge is failing thinking ![image](https://user-images.githubusercontent.com/35374649/215262389-0e590ff4-d111-4e36-832c-358f9c1832aa.png)

I quickly checked why the shields were not working. It looks like that although the PR works locally, Vercel gives a 403 error:
- Local endpoint: http://localhost:3000/api/status/up?type=shields
- Remote endpoint: https://github-readme-stats-git-monitoring-github-readme-stats-team.vercel.app/api/status/up?type=shields 

I use https://shields.io/endpoint for the badge. I am not a member of the Vercel GRS team, so I can not see what goes wrong. 😅 

Patch:
diff --git a/api/status/pat-info.js b/api/status/pat-info.js
new file mode 100644
index 0000000..720611d
--- /dev/null
+++ b/api/status/pat-info.js
@@ -0,0 +1,131 @@
+/**
+ * @file Contains a simple cloud function that can be used to check which PATs are no
+ * longer working. It returns a list of valid PATs, expired PATs and PATs with errors.
+ *
+ * @description This function is currently rate limited to 1 request per 10 minutes.
+ */
+
+import { logger, request, dateDiff } from "../../src/common/utils.js";
+export const RATE_LIMIT_SECONDS = 60 * 10; // 1 request per 10 minutes
+
+/**
+ * Simple uptime check fetcher for the PATs.
+ *
+ * @param {import('axios').AxiosRequestHeaders} variables
+ * @param {string} token
+ */
+const uptimeFetcher = (variables, token) => {
+  return request(
+    {
+      query: `
+        query {
+          rateLimit {
+            remaining
+            resetAt
+          },
+        }`,
+      variables,
+    },
+    {
+      Authorization: `bearer ${token}`,
+    },
+  );
+};
+
+const getAllPATs = () => {
+  return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
+};
+
+/**
+ * Check whether any of the PATs is expired.
+ */
+const getPATInfo = async (fetcher, variables) => {
+  const details = {};
+  const PATs = getAllPATs();
+
+  for (const pat of PATs) {
+    try {
+      const response = await fetcher(variables, process.env[pat]);
+      const errors = response.data.errors;
+      const hasErrors = Boolean(errors);
+      const errorType = errors?.[0]?.type;
+      const isRateLimited =
+        (hasErrors && errorType === "RATE_LIMITED") ||
+        response.data.data?.rateLimit?.remaining === 0;
+
+      // Store PATs with errors.
+      if (hasErrors && errorType !== "RATE_LIMITED") {
+        details[pat] = {
+          status: "error",
+          error: {
+            type: errors[0].type,
+            message: errors[0].message,
+          },
+        };
+        continue;
+      } else if (isRateLimited) {
+        const date1 = new Date();
+        const date2 = new Date(response.data?.data?.rateLimit?.resetAt);
+        details[pat] = {
+          status: "exhausted",
+          remaining: 0,
+          resetIn: dateDiff(date2, date1) + " minutes",
+        };
+      } else {
+        details[pat] = {
+          status: "valid",
+          remaining: response.data.data.rateLimit.remaining,
+        };
+      }
+    } catch (err) {
+      // Store the PAT if it is expired.
+      const errorMessage = err.response?.data?.message?.toLowerCase();
+      if (errorMessage === "bad credentials") {
+        details[pat] = {
+          status: "expired",
+        };
+      } else if (errorMessage === "sorry. your account was suspended.") {
+        details[pat] = {
+          status: "suspended",
+        };
+      } else {
+        throw err;
+      }
+    }
+  }
+
+  const filterPATsByStatus = (status) => {
+    return Object.keys(details).filter((pat) => details[pat].status === status);
+  };
+
+  return {
+    validPATs: filterPATsByStatus("valid"),
+    expiredPATs: filterPATsByStatus("expired"),
+    exhaustedPATS: filterPATsByStatus("exhausted"),
+    errorPATs: filterPATsByStatus("error"),
+    details,
+  };
+};
+
+/**
+ * Cloud function that returns information about the used PATs.
+ */
+export default async (_, res) => {
+  res.setHeader("Content-Type", "application/json");
+  try {
+    // Add header to prevent abuse.
+    const PATsInfo = await getPATInfo(uptimeFetcher, {});
+    if (PATsInfo) {
+      res.setHeader(
+        "Cache-Control",
+        `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
+      );
+    }
+    res.send(JSON.stringify(PATsInfo, null, 2));
+  } catch (err) {
+    // Throw error if something went wrong.
+    logger.error(err);
+    res.setHeader("Cache-Control", "no-store");
+    res.send("Something went wrong: " + err.message);
+  }
+};
diff --git a/api/status/up.js b/api/status/up.js
new file mode 100644
index 0000000..33fe8f9
--- /dev/null
+++ b/api/status/up.js
@@ -0,0 +1,103 @@
+/**
+ * @file Contains a simple cloud function that can be used to check if the PATs are still
+ * functional.
+ *
+ * @description This function is currently rate limited to 1 request per 10 minutes.
+ */
+
+import retryer from "../../src/common/retryer.js";
+import { logger, request } from "../../src/common/utils.js";
+
+export const RATE_LIMIT_SECONDS = 60 * 10; // 1 request per 10 minutes
+
+/**
+ * Simple uptime check fetcher for the PATs.
+ *
+ * @param {import('axios').AxiosRequestHeaders} variables
+ * @param {string} token
+ */
+const uptimeFetcher = (variables, token) => {
+  return request(
+    {
+      query: `
+        query {
+          rateLimit {
+              remaining
+          }
+        }
+        `,
+      variables,
+    },
+    {
+      Authorization: `bearer ${token}`,
+    },
+  );
+};
+
+/**
+ * Creates Json response that can be used for shields.io dynamic card generation.
+ *
+ * @param {*} up Whether the PATs are up or not.
+ * @returns Dynamic shields.io JSON response object.
+ *
+ * @see https://shields.io/endpoint.
+ */
+const shieldsUptimeBadge = (up) => {
+  const schemaVersion = 1;
+  const isError = true;
+  const label = "Public Instance";
+  const message = up ? "up" : "down";
+  const color = up ? "brightgreen" : "red";
+  return {
+    schemaVersion,
+    label,
+    message,
+    color,
+    isError,
+  };
+};
+
+/**
+ * Cloud function that returns whether the PATs are still functional.
+ */
+export default async (req, res) => {
+  let { type } = req.query;
+  type = type ? type.toLowerCase() : "boolean";
+
+  res.setHeader("Content-Type", "application/json");
+
+  try {
+    let PATsValid = true;
+    try {
+      await retryer(uptimeFetcher, {});
+    } catch (err) {
+      PATsValid = false;
+    }
+
+    if (PATsValid) {
+      res.setHeader(
+        "Cache-Control",
+        `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
+      );
+    } else {
+      res.setHeader("Cache-Control", "no-store");
+    }
+
+    switch (type) {
+      case "shields":
+        res.send(shieldsUptimeBadge(PATsValid));
+        break;
+      case "json":
+        res.send({ up: PATsValid });
+        break;
+      default:
+        res.send(PATsValid);
+        break;
+    }
+  } catch (err) {
+    // Return fail boolean if something went wrong.
+    logger.error(err);
+    res.setHeader("Cache-Control", "no-store");
+    res.send("Something went wrong: " + err.message);
+  }
+};
diff --git a/package-lock.json b/package-lock.json
index 048c316..ebc7570 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,7 @@
         "@testing-library/dom": "^8.17.1",
         "@testing-library/jest-dom": "^5.16.5",
         "@uppercod/css-to-object": "^1.1.1",
-        "axios-mock-adapter": "^1.18.1",
+        "axios-mock-adapter": "^1.21.2",
         "color-contrast-checker": "^2.1.0",
         "hjson": "^3.2.2",
         "husky": "^8.0.0",
diff --git a/package.json b/package.json
index 95b1a11..606d4f5 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
     "@testing-library/dom": "^8.17.1",
     "@testing-library/jest-dom": "^5.16.5",
     "@uppercod/css-to-object": "^1.1.1",
-    "axios-mock-adapter": "^1.18.1",
+    "axios-mock-adapter": "^1.21.2",
     "color-contrast-checker": "^2.1.0",
     "hjson": "^3.2.2",
     "husky": "^8.0.0",
diff --git a/src/common/utils.js b/src/common/utils.js
index 1215fc9..c600c71 100644
--- a/src/common/utils.js
+++ b/src/common/utils.js
@@ -424,6 +424,19 @@ const parseEmojis = (str) => {
   });
 };
 
+/**
+ * Get diff in minutes
+ * @param {Date} d1
+ * @param {Date} d2
+ * @returns {number}
+ */
+const dateDiff = (d1, d2) => {
+  const date1 = new Date(d1);
+  const date2 = new Date(d2);
+  const diff = date1.getTime() - date2.getTime();
+  return Math.round(diff / (1000 * 60));
+};
+
 export {
   ERROR_CARD_LENGTH,
   renderError,
@@ -447,4 +460,5 @@ export {
   lowercaseTrim,
   chunkArray,
   parseEmojis,
+  dateDiff,
 };


Test patch:
diff --git a/tests/pat-info.test.js b/tests/pat-info.test.js
new file mode 100644
index 0000000..9635ab2
--- /dev/null
+++ b/tests/pat-info.test.js
@@ -0,0 +1,241 @@
+/**
+ * @file Tests for the status/pat-info cloud function.
+ */
+import dotenv from "dotenv";
+dotenv.config();
+
+import { jest } from "@jest/globals";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import patInfo, { RATE_LIMIT_SECONDS } from "../api/status/pat-info.js";
+
+const mock = new MockAdapter(axios);
+
+const successData = {
+  data: {
+    rateLimit: {
+      remaining: 4986,
+    },
+  },
+};
+
+const faker = (query) => {
+  const req = {
+    query: { ...query },
+  };
+  const res = {
+    setHeader: jest.fn(),
+    send: jest.fn(),
+  };
+
+  return { req, res };
+};
+
+const rate_limit_error = {
+  errors: [
+    {
+      type: "RATE_LIMITED",
+      message: "API rate limit exceeded for user ID.",
+    },
+  ],
+  data: {
+    rateLimit: {
+      resetAt: Date.now(),
+    },
+  },
+};
+
+const other_error = {
+  errors: [
+    {
+      type: "SOME_ERROR",
+      message: "This is a error",
+    },
+  ],
+};
+
+const bad_credentials_error = {
+  message: "Bad credentials",
+};
+
+afterEach(() => {
+  mock.reset();
+});
+
+describe("Test /api/status/pat-info", () => {
+  beforeAll(() => {
+    // reset patenv first so that dotenv doesn't populate them with local envs
+    process.env = {};
+    process.env.PAT_1 = "testPAT1";
+    process.env.PAT_2 = "testPAT2";
+    process.env.PAT_3 = "testPAT3";
+    process.env.PAT_4 = "testPAT4";
+  });
+
+  it("should return only 'validPATs' if all PATs are valid", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, rate_limit_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: [],
+          exhaustedPATS: ["PAT_1"],
+          errorPATs: [],
+          details: {
+            PAT_1: {
+              status: "exhausted",
+              remaining: 0,
+              resetIn: "0 minutes",
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should return `errorPATs` if a PAT causes an error to be thrown", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, other_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: [],
+          exhaustedPATS: [],
+          errorPATs: ["PAT_1"],
+          details: {
+            PAT_1: {
+              status: "error",
+              error: {
+                type: "SOME_ERROR",
+                message: "This is a error",
+              },
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should return `expiredPaths` if a PAT returns a 'Bad credentials' error", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(404, bad_credentials_error)
+      .onPost("https://api.github.com/graphql")
+      .reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(
+      JSON.stringify(
+        {
+          validPATs: ["PAT_2", "PAT_3", "PAT_4"],
+          expiredPATs: ["PAT_1"],
+          exhaustedPATS: [],
+          errorPATs: [],
+          details: {
+            PAT_1: {
+              status: "expired",
+            },
+            PAT_2: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_3: {
+              status: "valid",
+              remaining: 4986,
+            },
+            PAT_4: {
+              status: "valid",
+              remaining: 4986,
+            },
+          },
+        },
+        null,
+        2,
+      ),
+    );
+  });
+
+  it("should throw an error if something goes wrong", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith("Something went wrong: Network Error");
+  });
+
+  it("should have proper cache when no error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, successData);
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`],
+    ]);
+  });
+
+  it("should have proper cache when error is thrown", async () => {
+    mock.reset();
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await patInfo(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", "no-store"],
+    ]);
+  });
+});
diff --git a/tests/status.up.test.js b/tests/status.up.test.js
new file mode 100644
index 0000000..7cf0144
--- /dev/null
+++ b/tests/status.up.test.js
@@ -0,0 +1,194 @@
+/**
+ * @file Tests for the status/up cloud function.
+ */
+import { jest } from "@jest/globals";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import up, { RATE_LIMIT_SECONDS } from "../api/status/up.js";
+
+const mock = new MockAdapter(axios);
+
+const successData = {
+  rateLimit: {
+    remaining: 4986,
+  },
+};
+
+const faker = (query) => {
+  const req = {
+    query: { ...query },
+  };
+  const res = {
+    setHeader: jest.fn(),
+    send: jest.fn(),
+  };
+
+  return { req, res };
+};
+
+const rate_limit_error = {
+  errors: [
+    {
+      type: "RATE_LIMITED",
+    },
+  ],
+};
+
+const bad_credentials_error = {
+  message: "Bad credentials",
+};
+
+const shields_up = {
+  schemaVersion: 1,
+  label: "Public Instance",
+  isError: true,
+  message: "up",
+  color: "brightgreen",
+};
+const shields_down = {
+  schemaVersion: 1,
+  label: "Public Instance",
+  isError: true,
+  message: "down",
+  color: "red",
+};
+
+afterEach(() => {
+  mock.reset();
+});
+
+describe("Test /api/status/up", () => {
+  it("should return `true` if request was successful", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `false` if all PATs are rate limited", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should return JSON `true` if request was successful and type='json'", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({ type: "json" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith({ up: true });
+  });
+
+  it("should return JSON `false` if all PATs are rate limited and type='json'", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({ type: "json" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith({ up: false });
+  });
+
+  it("should return UP shields.io config if request was successful and type='shields'", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({ type: "shields" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(shields_up);
+  });
+
+  it("should return DOWN shields.io config if all PATs are rate limited and type='shields'", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, rate_limit_error);
+
+    const { req, res } = faker({ type: "shields" }, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(shields_down);
+  });
+
+  it("should return `true` if the first PAT is rate limited but the second PATs works", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, rate_limit_error)
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `true` if the first PAT has 'Bad credentials' but the second PAT works", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(404, bad_credentials_error)
+      .onPost("https://api.github.com/graphql")
+      .replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(true);
+  });
+
+  it("should return `false` if all pats have 'Bad credentials'", async () => {
+    mock
+      .onPost("https://api.github.com/graphql")
+      .reply(404, bad_credentials_error);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should throw an error if the request fails", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader).toBeCalledWith("Content-Type", "application/json");
+    expect(res.send).toBeCalledWith(false);
+  });
+
+  it("should have proper cache when no error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").replyOnce(200, successData);
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`],
+    ]);
+  });
+
+  it("should have proper cache when error is thrown", async () => {
+    mock.onPost("https://api.github.com/graphql").networkError();
+
+    const { req, res } = faker({}, {});
+    await up(req, res);
+
+    expect(res.setHeader.mock.calls).toEqual([
+      ["Content-Type", "application/json"],
+      ["Cache-Control", "no-store"],
+    ]);
+  });
+});