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 PAT monitoring functions #2178

Merged
merged 23 commits into from
Jan 28, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a505538
feat: add PAT monitoring functions
rickstaa Oct 4, 2022
c239673
feat: add shields.io dynamic badge json response
rickstaa Oct 11, 2022
26bc4bc
feat: add 'json' type to up monitor
rickstaa Oct 11, 2022
c261bdb
feat: cleanup status functions
rickstaa Oct 11, 2022
cf6e7c5
ci: decrease pat-info rate limiting time
rickstaa Oct 18, 2022
85b4349
feat: decrease monitoring functions rate limits
rickstaa Oct 25, 2022
fbf2793
refactor: pat code
anuraghazra Nov 23, 2022
cd9e685
feat: add PAT monitoring functions
rickstaa Oct 4, 2022
a44cd16
feat: add shields.io dynamic badge json response
rickstaa Oct 11, 2022
964ad66
feat: add 'json' type to up monitor
rickstaa Oct 11, 2022
3c0aae1
feat: cleanup status functions
rickstaa Oct 11, 2022
c502d55
ci: decrease pat-info rate limiting time
rickstaa Oct 18, 2022
3303f90
feat: decrease monitoring functions rate limits
rickstaa Oct 25, 2022
106ce11
refactor: pat code
anuraghazra Nov 23, 2022
b866378
Merge branch 'monitoring' of https://github.com/anuraghazra/github-re…
anuraghazra Jan 28, 2023
169fa96
test: fix pat-info tests
rickstaa Jan 28, 2023
6015466
Update api/status/pat-info.js
rickstaa Jan 28, 2023
8c4feef
test: fix broken tests
rickstaa Jan 28, 2023
b2381ee
Merge branch 'monitoring' of https://github.com/anuraghazra/github-re…
anuraghazra Jan 28, 2023
d3d1d1a
chore: fix suspended account
anuraghazra Jan 28, 2023
2f14ed0
chore: simplify and refactor
anuraghazra Jan 28, 2023
bcde859
chore: fix test
anuraghazra Jan 28, 2023
b79cc44
chore: add resetIn field
anuraghazra Jan 28, 2023
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
101 changes: 101 additions & 0 deletions api/status/pat-info.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @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 day.
*/

import { logger, request } from "../../src/common/utils.js";

export const RATE_LIMIT_SECONDS = 60 * 60 * 24; // 1 request per day.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change this limit to our likings.


/**
* 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}`,
},
);
};

/**
* Check whether any of the PATs is expired.
*/
const getPATInfo = async (fetcher, variables) => {
let PATsInfo = { expiredPATs: [], errorPATs: [], validPATs: [] };
const PATs = Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key));
for (const pat of PATs) {
try {
const response = await fetcher(variables, process.env[pat]);
response.data.errors && response.data.errors[0].type === "RATE_LIMITED";

// Store PATs with errors.
if (
response.data.errors &&
!(response.data.errors[0].type === "RATE_LIMITED")
) {
PATsInfo.errorPATs.push({
[pat]: {
type: response.data.errors[0].type,
message: response.data.errors[0].message,
},
});
continue;
}

// Store remaining PATs.
PATsInfo.validPATs.push(pat);
} catch (err) {
const isBadCredential =
err.response &&
err.response.data &&
err.response.data.message === "Bad credentials";

// Store the PAT if it is expired.
if (isBadCredential) {
PATsInfo.expiredPATs.push(pat);
} else {
throw err;
}
}
}
return PATsInfo;
};

/**
* 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, {});
rickstaa marked this conversation as resolved.
Show resolved Hide resolved
if (pATsInfo) {
res.setHeader(
"Cache-Control",
`max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
);
}
res.send(pATsInfo);
} catch (err) {
// Throw error if something went wrong.
logger.error(err);
res.setHeader("Cache-Control", "no-store");
res.send("Something went wrong: " + err.message);
}
};
112 changes: 112 additions & 0 deletions api/status/up.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* @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 15 minutes.
*/

import { logger, request } from "../../src/common/utils.js";

// Script variables.
const PATs = Object.keys(process.env).filter((key) =>
/PAT_\d*$/.exec(key),
).length;
const RETRIES = PATs ? PATs : 7;
export const RATE_LIMIT_SECONDS = 60 * 15; // 1 request per 15 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}`,
},
);
};

/**
* Check whether any of the PATs is still functional.
*
* @param {Object} fetcher Fetcher object.
* @param {Object} variables Fetcher variables.
* @param {number} retries How many times to retry.
*/
const PATsWorking = async (fetcher, variables, retries = 0) => {
if (retries > RETRIES) {
// Return false if no PAT is working.
return false;
}

// Loop through PATs to see if any of them are working.
try {
const response = await fetcher(
variables,
process.env[`PAT_${retries + 1}`],
);

const isRateExceeded =
response.data.errors && response.data.errors[0].type === "RATE_LIMITED";

// If rate limit is hit increase RETRIES and recursively call the PATsWorking
// with username, and current RETRIES
if (isRateExceeded) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
return PATsWorking(fetcher, variables, retries);
}

return true; // Return true if a PAT was working.
} catch (err) {
// also checking for bad credentials if any tokens gets invalidated
const isBadCredential =
err.response &&
err.response.data &&
err.response.data.message === "Bad credentials";

if (isBadCredential) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
// directly return from the function
return PATsWorking(fetcher, variables, retries);
} else {
throw err;
}
}
};

/**
* Cloud function that returns whether the PATs are still functional.
*/
export default async (_, res) => {
res.setHeader("Content-Type", "application/json");
try {
// Add header to prevent abuse.
const PATsValid = await PATsWorking(uptimeFetcher, {});
if (PATsValid) {
res.setHeader(
"Cache-Control",
`max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`,
);
}
res.send(PATsValid);
} 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);
}
};
154 changes: 154 additions & 0 deletions tests/pat-info.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @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 = {
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 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(() => {
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({
errorPATs: [],
expiredPATs: [],
validPATs: ["PAT_1", "PAT_2", "PAT_3", "PAT_4"],
});
});

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({
errorPATs: [
{ PAT_1: { type: "SOME_ERROR", message: "This is a error" } },
],
expiredPATs: [],
validPATs: ["PAT_2", "PAT_3", "PAT_4"],
});
});

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({
errorPATs: [],
expiredPATs: ["PAT_1"],
validPATs: ["PAT_2", "PAT_3", "PAT_4"],
});
});

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.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"],
]);
});
});
Loading