mirror of
https://github.com/expo/explore.git
synced 2026-11-14 10:08:17 -10:00
Co-authored-by: Brent Vatne <[email protected]> Co-authored-by: Szymon Dziedzic <[email protected]>
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { CI_COMMENT_MARKER, upsertStickyComment } from "../../dist/report/pr-comment.js";
|
|
|
|
const repository = "acme/mobile-app";
|
|
const issueNumber = 42;
|
|
const token = "github-token-do-not-leak";
|
|
const authorLogin = "github-actions[bot]";
|
|
const body = `${CI_COMMENT_MARKER}\n\n## Explore report\n\n2 screens changed.`;
|
|
|
|
function jsonResponse(value, init = {}) {
|
|
return new Response(JSON.stringify(value), {
|
|
...init,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...init.headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
function requestRecord(input, init = {}) {
|
|
return {
|
|
url: String(input),
|
|
method: init.method ?? "GET",
|
|
headers: new Headers(init.headers),
|
|
body: init.body,
|
|
redirect: init.redirect,
|
|
signal: init.signal,
|
|
};
|
|
}
|
|
|
|
function assertGitHubHeaders(request) {
|
|
assert.equal(request.headers.get("authorization"), `Bearer ${token}`);
|
|
assert.equal(request.headers.get("accept"), "application/vnd.github+json");
|
|
assert.equal(request.headers.get("x-github-api-version"), "2022-11-28");
|
|
}
|
|
|
|
test("paginates comments and updates only the marker comment from the requested author", async () => {
|
|
const apiUrl = "https://github.example/api/v3/";
|
|
const pageOneUrl = `${apiUrl}repos/${repository}/issues/${issueNumber}/comments?per_page=100&page=1`;
|
|
const pageTwoUrl = `${apiUrl}repos/${repository}/issues/${issueNumber}/comments?per_page=100&page=2`;
|
|
const updateUrl = `${apiUrl}repos/${repository}/issues/comments/314`;
|
|
const calls = [];
|
|
|
|
const fetchImpl = async (input, init) => {
|
|
const request = requestRecord(input, init);
|
|
calls.push(request);
|
|
|
|
if (request.url === pageOneUrl && request.method === "GET") {
|
|
return jsonResponse(
|
|
[
|
|
{
|
|
id: 111,
|
|
body: `${CI_COMMENT_MARKER}\n\nThis belongs to another bot.`,
|
|
user: { login: "another-bot[bot]" },
|
|
},
|
|
{
|
|
id: 112,
|
|
body: "A regular comment without the sticky marker.",
|
|
user: { login: authorLogin },
|
|
},
|
|
],
|
|
{
|
|
headers: {
|
|
link: `<${pageTwoUrl}>; rel="next", <${pageTwoUrl}>; rel="last"`,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
if (request.url === pageTwoUrl && request.method === "GET") {
|
|
return jsonResponse([
|
|
{
|
|
id: 314,
|
|
body: `${CI_COMMENT_MARKER}\n\nPrevious Explore report.`,
|
|
user: { login: authorLogin },
|
|
},
|
|
]);
|
|
}
|
|
|
|
if (request.url === updateUrl && request.method === "PATCH") {
|
|
return jsonResponse({
|
|
id: 314,
|
|
html_url: "https://github.example/acme/mobile-app/issues/42#issuecomment-314",
|
|
});
|
|
}
|
|
|
|
throw new Error(`Unexpected request: ${request.method} ${request.url}`);
|
|
};
|
|
|
|
const result = await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
apiUrl,
|
|
trustedApiOrigin: "https://github.example",
|
|
fetchImpl,
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
operation: "updated",
|
|
commentId: 314,
|
|
url: "https://github.example/acme/mobile-app/issues/42#issuecomment-314",
|
|
previousBody: "<!-- expo-explore-ci -->\n\nPrevious Explore report.",
|
|
});
|
|
assert.deepEqual(
|
|
calls.map(({ method, url }) => ({ method, url })),
|
|
[
|
|
{ method: "GET", url: pageOneUrl },
|
|
{ method: "GET", url: pageTwoUrl },
|
|
{ method: "PATCH", url: updateUrl },
|
|
],
|
|
);
|
|
|
|
for (const request of calls) {
|
|
assertGitHubHeaders(request);
|
|
assert.equal(request.redirect, "error");
|
|
assert.ok(request.signal instanceof AbortSignal);
|
|
}
|
|
assert.equal(calls[0].body, undefined);
|
|
assert.equal(calls[1].body, undefined);
|
|
assert.equal(calls[2].headers.get("content-type"), "application/json");
|
|
assert.deepEqual(JSON.parse(calls[2].body), { body });
|
|
});
|
|
|
|
test("creates a new sticky comment when only another author's marker comment exists", async () => {
|
|
const listUrl = `https://api.github.com/repos/${repository}/issues/${issueNumber}/comments?per_page=100&page=1`;
|
|
const createUrl = `https://api.github.com/repos/${repository}/issues/${issueNumber}/comments`;
|
|
const calls = [];
|
|
|
|
const fetchImpl = async (input, init) => {
|
|
const request = requestRecord(input, init);
|
|
calls.push(request);
|
|
|
|
if (request.url === listUrl && request.method === "GET") {
|
|
return jsonResponse([
|
|
{
|
|
id: 271,
|
|
body: `${CI_COMMENT_MARKER}\n\nDo not overwrite this comment.`,
|
|
user: { login: "another-bot[bot]" },
|
|
},
|
|
{
|
|
id: 272,
|
|
body: `A quoted marker is not owned:\n${CI_COMMENT_MARKER}`,
|
|
user: { login: authorLogin },
|
|
},
|
|
]);
|
|
}
|
|
|
|
if (request.url === createUrl && request.method === "POST") {
|
|
return jsonResponse({ id: 315 }, { status: 201 });
|
|
}
|
|
|
|
throw new Error(`Unexpected request: ${request.method} ${request.url}`);
|
|
};
|
|
|
|
const result = await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
fetchImpl,
|
|
});
|
|
|
|
assert.equal(result.operation, "created");
|
|
assert.equal(result.commentId, 315);
|
|
assert.equal(result.url, undefined);
|
|
assert.deepEqual(
|
|
calls.map(({ method, url }) => ({ method, url })),
|
|
[
|
|
{ method: "GET", url: listUrl },
|
|
{ method: "POST", url: createUrl },
|
|
],
|
|
);
|
|
for (const request of calls) {
|
|
assertGitHubHeaders(request);
|
|
assert.equal(request.redirect, "error");
|
|
}
|
|
assert.equal(calls[1].headers.get("content-type"), "application/json");
|
|
assert.deepEqual(JSON.parse(calls[1].body), { body });
|
|
});
|
|
|
|
test("reports non-success responses without exposing the GitHub token", async () => {
|
|
const fetchImpl = async () => jsonResponse(
|
|
{ message: `Bad credentials: ${token}` },
|
|
{ status: 401, statusText: "Unauthorized" },
|
|
);
|
|
|
|
let caught;
|
|
try {
|
|
await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
fetchImpl,
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
assert.ok(caught instanceof Error);
|
|
assert.match(caught.message, /GitHub|comment|401|Unauthorized/i);
|
|
assert.doesNotMatch(caught.message, new RegExp(token));
|
|
assert.doesNotMatch(caught.stack ?? "", new RegExp(token));
|
|
});
|
|
|
|
test("retries transient reads but does not blindly retry a comment-creation POST", async () => {
|
|
const listUrl = `https://api.github.com/repos/${repository}/issues/${issueNumber}/comments?per_page=100&page=1`;
|
|
const createUrl = `https://api.github.com/repos/${repository}/issues/${issueNumber}/comments`;
|
|
const calls = [];
|
|
|
|
const fetchImpl = async (input, init) => {
|
|
const request = requestRecord(input, init);
|
|
calls.push(request);
|
|
if (request.url === listUrl && calls.length === 1) {
|
|
return jsonResponse(
|
|
{ message: `temporary failure: ${token}` },
|
|
{ status: 503, headers: { "retry-after": "0" } },
|
|
);
|
|
}
|
|
if (request.url === listUrl && calls.length === 2) return jsonResponse([]);
|
|
if (request.url === createUrl) {
|
|
return jsonResponse(
|
|
{ message: `ambiguous failure: ${token}` },
|
|
{ status: 503, headers: { "retry-after": "0" } },
|
|
);
|
|
}
|
|
throw new Error(`Unexpected request: ${request.method} ${request.url}`);
|
|
};
|
|
|
|
let caught;
|
|
try {
|
|
await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
fetchImpl,
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
assert.ok(caught instanceof Error);
|
|
assert.equal(caught.message, "GitHub comment request failed with HTTP 503");
|
|
assert.doesNotMatch(caught.message, new RegExp(token));
|
|
assert.deepEqual(
|
|
calls.map(({ method, url }) => ({ method, url })),
|
|
[
|
|
{ method: "GET", url: listUrl },
|
|
{ method: "GET", url: listUrl },
|
|
{ method: "POST", url: createUrl },
|
|
],
|
|
);
|
|
});
|
|
|
|
test("bounds stalled requests even when the transport ignores abort", async () => {
|
|
let requestCount = 0;
|
|
let abortCount = 0;
|
|
const fetchImpl = async (_input, init) => {
|
|
requestCount += 1;
|
|
init.signal.addEventListener("abort", () => {
|
|
abortCount += 1;
|
|
}, { once: true });
|
|
return await new Promise(() => {
|
|
// Deliberately ignore abort to prove the caller's own deadline is bounded.
|
|
});
|
|
};
|
|
|
|
let caught;
|
|
try {
|
|
await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
requestTimeoutMs: 5,
|
|
fetchImpl,
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
assert.ok(caught instanceof Error);
|
|
assert.equal(caught.message, "GitHub comment request timed out after 5ms");
|
|
assert.doesNotMatch(caught.message, new RegExp(token));
|
|
assert.doesNotMatch(caught.stack ?? "", new RegExp(token));
|
|
assert.equal(requestCount, 3);
|
|
assert.equal(abortCount, 3);
|
|
});
|
|
|
|
test("sanitizes repeated network failures", async () => {
|
|
let requestCount = 0;
|
|
const fetchImpl = async () => {
|
|
requestCount += 1;
|
|
throw new Error(`network failure containing ${token}`);
|
|
};
|
|
|
|
let caught;
|
|
try {
|
|
await upsertStickyComment({
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
fetchImpl,
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
assert.ok(caught instanceof Error);
|
|
assert.equal(caught.message, "GitHub comment request failed after a network error");
|
|
assert.doesNotMatch(caught.message, new RegExp(token));
|
|
assert.doesNotMatch(caught.stack ?? "", new RegExp(token));
|
|
assert.equal(requestCount, 3);
|
|
});
|
|
|
|
test("rejects invalid inputs before making a request", async () => {
|
|
let requestCount = 0;
|
|
const fetchImpl = async () => {
|
|
requestCount += 1;
|
|
throw new Error("fetch should not be called");
|
|
};
|
|
const valid = {
|
|
repository,
|
|
issueNumber,
|
|
body,
|
|
token,
|
|
authorLogin,
|
|
fetchImpl,
|
|
};
|
|
const invalidInputs = [
|
|
{ name: "empty body", input: { ...valid, body: "" }, pattern: /body|marker/i },
|
|
{
|
|
name: "body without marker",
|
|
input: { ...valid, body: "## Explore report" },
|
|
pattern: /body|marker/i,
|
|
},
|
|
{
|
|
name: "marker after leading whitespace",
|
|
input: { ...valid, body: ` ${body}` },
|
|
pattern: /begin|start|marker/i,
|
|
},
|
|
{ name: "empty repository", input: { ...valid, repository: "" }, pattern: /repository/i },
|
|
{ name: "owner only", input: { ...valid, repository: "acme" }, pattern: /repository/i },
|
|
{
|
|
name: "extra repository segment",
|
|
input: { ...valid, repository: "acme/mobile/app" },
|
|
pattern: /repository/i,
|
|
},
|
|
{ name: "zero issue", input: { ...valid, issueNumber: 0 }, pattern: /issue/i },
|
|
{ name: "negative issue", input: { ...valid, issueNumber: -1 }, pattern: /issue/i },
|
|
{ name: "fractional issue", input: { ...valid, issueNumber: 1.5 }, pattern: /issue/i },
|
|
{ name: "string issue", input: { ...valid, issueNumber: "42" }, pattern: /issue/i },
|
|
{
|
|
name: "insecure public API URL",
|
|
input: { ...valid, apiUrl: "http://api.github.com/" },
|
|
pattern: /apiUrl|https/i,
|
|
},
|
|
{
|
|
name: "custom API without an explicit trusted origin",
|
|
input: { ...valid, apiUrl: "https://github.example/api/v3/" },
|
|
pattern: /trustedApiOrigin/i,
|
|
},
|
|
{
|
|
name: "custom API with a different trusted origin",
|
|
input: {
|
|
...valid,
|
|
apiUrl: "https://attacker.example/api/v3/",
|
|
trustedApiOrigin: "https://github.example",
|
|
},
|
|
pattern: /origin|trustedApiOrigin/i,
|
|
},
|
|
{
|
|
name: "trusted origin containing a path",
|
|
input: {
|
|
...valid,
|
|
apiUrl: "https://github.example/api/v3/",
|
|
trustedApiOrigin: "https://github.example/api/v3/",
|
|
},
|
|
pattern: /origin/i,
|
|
},
|
|
{
|
|
name: "zero request timeout",
|
|
input: { ...valid, requestTimeoutMs: 0 },
|
|
pattern: /requestTimeoutMs/i,
|
|
},
|
|
{
|
|
name: "excessive request timeout",
|
|
input: { ...valid, requestTimeoutMs: 60_001 },
|
|
pattern: /requestTimeoutMs/i,
|
|
},
|
|
];
|
|
|
|
for (const { name, input, pattern } of invalidInputs) {
|
|
await assert.rejects(upsertStickyComment(input), pattern, name);
|
|
}
|
|
assert.equal(requestCount, 0);
|
|
});
|