mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-03 16:02:33 +00:00
5b6da4c588
* Add PR Artifact Explorer canvas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d * Address PR #2341 review feedback and codespell CI failures PR review fixes: - zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries - zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush - zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases - preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection - cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing - cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated - server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks - trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown - extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error - server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete - README.md: remove Markdown-rendering claim (files are shown as escaped text) CI fix: - .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address second round of PR #2341 review feedback - github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field) - cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races - cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation - accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com - preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one) - zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address remaining PR artifact explorer feedback Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 * Fix PR artifact explorer review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 * Stabilize artifact discovery and cache cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 --------- Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
567 lines
16 KiB
JavaScript
567 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { request as httpRequest } from "node:http";
|
|
import { readFile } from "node:fs/promises";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
analyzeArtifact,
|
|
hasRootIndexHtml,
|
|
} from "./detector.mjs";
|
|
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
|
import {
|
|
enrichPullRequests,
|
|
filterPullRequests,
|
|
listPullRequestArtifacts,
|
|
} from "./github.mjs";
|
|
import { contentDeliveryMode, startInstance, stopInstance } from "./server.mjs";
|
|
import {
|
|
CAPABILITY_TOKEN_HEADER,
|
|
hasCapabilityToken,
|
|
isCanonicalHost,
|
|
isCrossSiteRequest,
|
|
requiresCapabilityToken,
|
|
} from "./security.mjs";
|
|
import {
|
|
DEFAULT_PREFS,
|
|
normalizeRepository,
|
|
normalizePrefs,
|
|
rememberRepository,
|
|
} from "./state.mjs";
|
|
|
|
const here = new URL(".", import.meta.url);
|
|
|
|
function jsonResponse(payload, status = 200) {
|
|
return {
|
|
headers: { get: () => "application/json" },
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => JSON.stringify(payload),
|
|
};
|
|
}
|
|
|
|
function rawStatus(url, headers) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = httpRequest(url, { headers }, (response) => {
|
|
response.resume();
|
|
response.on("end", () => resolve(response.statusCode));
|
|
});
|
|
request.on("error", reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((complete) => {
|
|
resolve = complete;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
test("malformed recent repository values normalize to an empty list", () => {
|
|
for (const recent of [{ unexpected: true }, 42]) {
|
|
const preferences = normalizePrefs({
|
|
repository: "github/awesome-copilot",
|
|
repositories: { recent },
|
|
});
|
|
assert.deepEqual(preferences.repositories.recent, [
|
|
"github/awesome-copilot",
|
|
]);
|
|
}
|
|
|
|
const fallback = normalizePrefs({
|
|
repository: "not a repository",
|
|
repositories: { recent: 42 },
|
|
});
|
|
assert.equal(fallback.repository, DEFAULT_PREFS.repository);
|
|
assert.deepEqual(fallback.repositories.recent, [DEFAULT_PREFS.repository]);
|
|
|
|
const preferences = {
|
|
repositories: { recent: { unexpected: true } },
|
|
};
|
|
rememberRepository(preferences, "github/awesome-copilot");
|
|
assert.deepEqual(preferences.repositories.recent, [
|
|
"github/awesome-copilot",
|
|
]);
|
|
});
|
|
|
|
test("artifact filtering pages repository artifacts once and caches head SHAs", async (t) => {
|
|
const originalFetch = globalThis.fetch;
|
|
const artifactRequests = [];
|
|
t.after(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
globalThis.fetch = async (url, options = {}) => {
|
|
const requestUrl = new URL(url);
|
|
if (requestUrl.pathname === "/graphql") {
|
|
const body = JSON.parse(options.body);
|
|
assert.equal(body.variables.owner, "example");
|
|
assert.equal(body.variables.name, "artifact-cache-test");
|
|
return jsonResponse({
|
|
data: {
|
|
repository: {
|
|
pull0: {
|
|
headRefOid: "head-with-artifact",
|
|
isDraft: false,
|
|
reviewDecision: null,
|
|
totalCommentsCount: 0,
|
|
commits: { nodes: [] },
|
|
},
|
|
pull1: {
|
|
headRefOid: "head-without-artifact",
|
|
isDraft: false,
|
|
reviewDecision: null,
|
|
totalCommentsCount: 0,
|
|
commits: { nodes: [] },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
assert.equal(
|
|
requestUrl.pathname,
|
|
"/repos/example/artifact-cache-test/actions/artifacts",
|
|
);
|
|
artifactRequests.push(requestUrl.search);
|
|
const page = Number(requestUrl.searchParams.get("page"));
|
|
if (page === 1) {
|
|
return jsonResponse({
|
|
total_count: 101,
|
|
artifacts: Array.from({ length: 100 }, (_, index) => ({
|
|
id: index + 1,
|
|
workflow_run: { head_sha: "unrelated-head" },
|
|
})),
|
|
});
|
|
}
|
|
assert.equal(page, 2);
|
|
return jsonResponse({
|
|
total_count: 101,
|
|
artifacts: [
|
|
{
|
|
id: 101,
|
|
workflow_run: { head_sha: "head-with-artifact" },
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const pulls = [{ number: 1 }, { number: 2 }];
|
|
const filters = { artifacts: "with" };
|
|
const first = await enrichPullRequests(
|
|
"token",
|
|
"example/artifact-cache-test",
|
|
pulls,
|
|
filters,
|
|
);
|
|
assert.deepEqual(
|
|
first.map((pull) => pull.hasArtifacts),
|
|
[true, false],
|
|
);
|
|
assert.deepEqual(
|
|
filterPullRequests(first, filters).map((pull) => pull.number),
|
|
[1],
|
|
);
|
|
assert.deepEqual(
|
|
filterPullRequests(first, { artifacts: "without" }).map(
|
|
(pull) => pull.number,
|
|
),
|
|
[2],
|
|
);
|
|
|
|
await enrichPullRequests(
|
|
"token",
|
|
"example/artifact-cache-test",
|
|
pulls,
|
|
filters,
|
|
);
|
|
assert.equal(artifactRequests.length, 2);
|
|
assert.match(artifactRequests[0], /per_page=100/);
|
|
assert.match(artifactRequests[0], /page=1/);
|
|
assert.match(artifactRequests[1], /page=2/);
|
|
});
|
|
|
|
test("pull artifact results report the intentional workflow-run cap", async (t) => {
|
|
const originalFetch = globalThis.fetch;
|
|
const artifactRequests = [];
|
|
t.after(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
globalThis.fetch = async (url) => {
|
|
const requestUrl = new URL(url);
|
|
if (requestUrl.pathname === "/repos/example/run-cap/pulls/7") {
|
|
return jsonResponse({
|
|
number: 7,
|
|
title: "Run cap",
|
|
state: "open",
|
|
head: { sha: "head-sha", ref: "feature" },
|
|
base: { ref: "main" },
|
|
user: { login: "octocat" },
|
|
labels: [],
|
|
});
|
|
}
|
|
if (requestUrl.pathname === "/repos/example/run-cap/actions/runs") {
|
|
assert.equal(requestUrl.searchParams.get("per_page"), "30");
|
|
return jsonResponse({
|
|
total_count: 35,
|
|
workflow_runs: Array.from({ length: 30 }, (_, index) => ({
|
|
id: index + 1,
|
|
name: "CI",
|
|
run_number: index + 1,
|
|
run_attempt: 1,
|
|
event: "pull_request",
|
|
status: "completed",
|
|
conclusion: "success",
|
|
created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, 35 - index)).toISOString(),
|
|
html_url: `https://github.com/example/run-cap/actions/runs/${index + 1}`,
|
|
})),
|
|
});
|
|
}
|
|
const artifactMatch = requestUrl.pathname.match(
|
|
/^\/repos\/example\/run-cap\/actions\/runs\/(\d+)\/artifacts$/,
|
|
);
|
|
assert.ok(artifactMatch);
|
|
artifactRequests.push(Number(artifactMatch[1]));
|
|
return jsonResponse({
|
|
total_count: 1,
|
|
artifacts: [
|
|
{
|
|
id: Number(artifactMatch[1]) * 10,
|
|
name: `artifact-${artifactMatch[1]}`,
|
|
size_in_bytes: 100,
|
|
expired: false,
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
expires_at: "2026-04-01T00:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const result = await listPullRequestArtifacts(
|
|
"token",
|
|
"example/run-cap",
|
|
7,
|
|
);
|
|
assert.equal(artifactRequests.length, 30);
|
|
assert.equal(result.runs.length, 30);
|
|
assert.equal(result.runCount, 35);
|
|
assert.equal(result.runsTruncated, true);
|
|
assert.equal(result.artifacts.length, 30);
|
|
assert.equal(result.artifactCount, 30);
|
|
assert.equal(result.artifactsTruncated, true);
|
|
});
|
|
|
|
test("only the archive root index uses static-site preview routing", async () => {
|
|
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
|
const helpers = source.slice(
|
|
source.indexOf("function artifactEntryKind"),
|
|
source.indexOf("function directoryPathsForFile"),
|
|
);
|
|
const shouldUseStaticSitePreview = Function(
|
|
`${helpers}; return shouldUseStaticSitePreview;`,
|
|
)();
|
|
const root = { path: "index.html", kind: "html", supported: true };
|
|
const nested = {
|
|
path: "docs/index.html",
|
|
kind: "html",
|
|
supported: true,
|
|
};
|
|
const unsupported = {
|
|
path: "index.html",
|
|
kind: "html",
|
|
supported: false,
|
|
};
|
|
const entries = [root, nested];
|
|
|
|
assert.equal(shouldUseStaticSitePreview(root, entries), true);
|
|
assert.equal(shouldUseStaticSitePreview(nested, entries), false);
|
|
assert.equal(shouldUseStaticSitePreview(nested, [nested]), false);
|
|
assert.equal(
|
|
shouldUseStaticSitePreview(unsupported, [unsupported]),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("completed TRX summaries derive their displayed outcome from counters", async () => {
|
|
const source = await readFile(
|
|
new URL("assets/trx-preview.js", here),
|
|
"utf8",
|
|
);
|
|
const helpers = source.slice(
|
|
source.indexOf("const KNOWN_TRX_OUTCOMES"),
|
|
source.indexOf("function parseDuration"),
|
|
);
|
|
const summarizedRunOutcome = Function(
|
|
`${helpers}; return summarizedRunOutcome;`,
|
|
)();
|
|
|
|
assert.equal(
|
|
summarizedRunOutcome({
|
|
outcome: "Completed",
|
|
summaryCounts: { failed: 1, passed: 4, skipped: 0 },
|
|
}),
|
|
"Failed",
|
|
);
|
|
assert.equal(
|
|
summarizedRunOutcome({
|
|
outcome: "Completed",
|
|
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
|
}),
|
|
"Passed",
|
|
);
|
|
assert.equal(
|
|
summarizedRunOutcome({
|
|
outcome: "Completed",
|
|
summaryCounts: { failed: 0, passed: 0, skipped: 2 },
|
|
}),
|
|
"Skipped",
|
|
);
|
|
assert.equal(
|
|
summarizedRunOutcome({
|
|
outcome: "Completed",
|
|
summaryCounts: { failed: 0, passed: 0, skipped: 0 },
|
|
}),
|
|
"Completed",
|
|
);
|
|
assert.equal(
|
|
summarizedRunOutcome({
|
|
outcome: "Failed",
|
|
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
|
}),
|
|
"Failed",
|
|
);
|
|
});
|
|
|
|
test("repository normalization rejects owner and name dot segments", () => {
|
|
for (const repository of ["../user", "./repo", "owner/..", "owner/."]) {
|
|
assert.throws(
|
|
() => normalizeRepository(repository),
|
|
/cannot be dot segments/,
|
|
);
|
|
}
|
|
assert.equal(
|
|
normalizeRepository("github/awesome-copilot"),
|
|
"github/awesome-copilot",
|
|
);
|
|
});
|
|
|
|
test("unsupported entries remain listed but cannot become the primary preview", async () => {
|
|
const analysis = await analyzeArtifact(
|
|
{
|
|
entries: [
|
|
{
|
|
name: "index.html",
|
|
directory: false,
|
|
supported: false,
|
|
compressedSize: 10,
|
|
uncompressedSize: 20,
|
|
},
|
|
{
|
|
name: "session.cast",
|
|
directory: false,
|
|
supported: false,
|
|
compressedSize: 10,
|
|
uncompressedSize: 20,
|
|
},
|
|
{
|
|
name: "results.trx",
|
|
directory: false,
|
|
supported: true,
|
|
compressedSize: 10,
|
|
uncompressedSize: 20,
|
|
},
|
|
],
|
|
totalUncompressedBytes: 60,
|
|
},
|
|
async () => {
|
|
throw new Error("Unsupported entries must not be probed.");
|
|
},
|
|
);
|
|
|
|
assert.equal(analysis.entries.length, 3);
|
|
assert.deepEqual(analysis.primary, {
|
|
kind: "trx",
|
|
path: "results.trx",
|
|
label: "Test results",
|
|
});
|
|
assert.equal(hasRootIndexHtml(analysis.entries), false);
|
|
});
|
|
|
|
test("unsupported entry downloads fall back to the original artifact archive", () => {
|
|
assert.equal(contentDeliveryMode({ supported: false }, false), "unsupported");
|
|
assert.equal(contentDeliveryMode({ supported: false }, true), "archive");
|
|
assert.equal(contentDeliveryMode({ supported: true }, true), "entry");
|
|
});
|
|
|
|
test("cache maintenance blocks inspections during artifact deletion", async () => {
|
|
const coordinator = new CacheMaintenanceCoordinator();
|
|
const deletionEntered = deferred();
|
|
const releaseDeletion = deferred();
|
|
const deletion = coordinator.deleteArtifact("42", async () => {
|
|
deletionEntered.resolve();
|
|
await releaseDeletion.promise;
|
|
return { deleted: "42" };
|
|
});
|
|
await deletionEntered.promise;
|
|
|
|
let inspectionStarted = false;
|
|
const inspection = (async () => {
|
|
while (true) {
|
|
const barrier = coordinator.inspectionBarrier("42");
|
|
if (!barrier) break;
|
|
await barrier;
|
|
}
|
|
inspectionStarted = true;
|
|
})();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(inspectionStarted, false);
|
|
assert.equal(coordinator.inspectionBarrier("99"), null);
|
|
|
|
releaseDeletion.resolve();
|
|
assert.deepEqual(await deletion, { deleted: "42" });
|
|
await inspection;
|
|
assert.equal(inspectionStarted, true);
|
|
});
|
|
|
|
test("cache clearing waits for deletions and blocks every inspection", async () => {
|
|
const coordinator = new CacheMaintenanceCoordinator();
|
|
const deletionEntered = deferred();
|
|
const releaseDeletion = deferred();
|
|
const deletion = coordinator.deleteArtifact("42", async () => {
|
|
deletionEntered.resolve();
|
|
await releaseDeletion.promise;
|
|
});
|
|
await deletionEntered.promise;
|
|
|
|
const clearEntered = deferred();
|
|
const releaseClear = deferred();
|
|
const clear = coordinator.clearCache(async () => {
|
|
clearEntered.resolve();
|
|
await releaseClear.promise;
|
|
return { cleared: true };
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(coordinator.inspectionBarrier("99") !== null, true);
|
|
|
|
let inspectionStarted = false;
|
|
const inspection = (async () => {
|
|
while (true) {
|
|
const barrier = coordinator.inspectionBarrier("99");
|
|
if (!barrier) break;
|
|
await barrier;
|
|
}
|
|
inspectionStarted = true;
|
|
})();
|
|
releaseDeletion.resolve();
|
|
await deletion;
|
|
await clearEntered.promise;
|
|
assert.equal(inspectionStarted, false);
|
|
|
|
releaseClear.resolve();
|
|
assert.deepEqual(await clear, { cleared: true });
|
|
await inspection;
|
|
assert.equal(inspectionStarted, true);
|
|
});
|
|
|
|
test("artifact truncation messaging names omitted workflow runs", async () => {
|
|
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
|
const helperSource = source.slice(
|
|
source.indexOf("function artifactResultLimitMessage"),
|
|
source.indexOf("function renderPullPayload"),
|
|
);
|
|
const artifactResultLimitMessage = Function(
|
|
`${helperSource}; return artifactResultLimitMessage;`,
|
|
)();
|
|
|
|
assert.equal(
|
|
artifactResultLimitMessage({
|
|
runs: Array.from({ length: 30 }),
|
|
runCount: 35,
|
|
runsTruncated: true,
|
|
artifacts: Array.from({ length: 100 }),
|
|
artifactCount: 120,
|
|
}),
|
|
"Showing artifacts from the newest 30 of 35 workflow runs. Showing the newest 100 of 120 artifacts from those runs.",
|
|
);
|
|
});
|
|
|
|
test("capability tokens protect loopback APIs, content, and events", () => {
|
|
const token = "secret-token";
|
|
const req = (headers = {}) => ({ headers });
|
|
const apiUrl = new URL("http://127.0.0.1:1234/api/bootstrap");
|
|
const contentUrl = new URL(
|
|
`http://127.0.0.1:1234/content/1/file.txt?token=${token}`,
|
|
);
|
|
const eventsUrl = new URL(
|
|
`http://127.0.0.1:1234/events?token=${token}`,
|
|
);
|
|
|
|
assert.equal(
|
|
isCanonicalHost(req({ host: "127.0.0.1:1234" }), "127.0.0.1:1234"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isCanonicalHost(req({ host: "attacker.example:1234" }), "127.0.0.1:1234"),
|
|
false,
|
|
);
|
|
assert.equal(requiresCapabilityToken("/api/bootstrap"), true);
|
|
assert.equal(requiresCapabilityToken("/content/1/file.txt"), true);
|
|
assert.equal(requiresCapabilityToken("/events"), true);
|
|
assert.equal(requiresCapabilityToken("/assets/app.js"), false);
|
|
assert.equal(
|
|
hasCapabilityToken(req({ [CAPABILITY_TOKEN_HEADER]: token }), apiUrl, token),
|
|
true,
|
|
);
|
|
assert.equal(hasCapabilityToken(req(), contentUrl, token), true);
|
|
assert.equal(hasCapabilityToken(req(), eventsUrl, token), true);
|
|
assert.equal(
|
|
hasCapabilityToken(
|
|
req(),
|
|
new URL(`${apiUrl}?token=${token}`),
|
|
token,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isCrossSiteRequest(
|
|
req({ origin: "https://attacker.example" }),
|
|
"http://127.0.0.1:1234",
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("canvas server rejects spoofed hosts and unauthenticated protected routes", async (t) => {
|
|
const instanceId = `review-security-${Date.now()}`;
|
|
const entry = await startInstance(instanceId, null, () => {});
|
|
t.after(() => stopInstance(instanceId));
|
|
|
|
const root = await fetch(entry.url);
|
|
assert.equal(root.status, 200);
|
|
assert.match(
|
|
await root.text(),
|
|
new RegExp(`name="pr-artifact-explorer-token" content="${entry.token}"`),
|
|
);
|
|
assert.equal((await fetch(`${entry.origin}/`)).status, 403);
|
|
|
|
for (const path of ["api/bootstrap", "content/1/file.txt", "events"]) {
|
|
const response = await fetch(`${entry.origin}/${path}`);
|
|
assert.equal(response.status, 403);
|
|
}
|
|
|
|
assert.equal(
|
|
await rawStatus(entry.url, { Host: "attacker.example" }),
|
|
403,
|
|
);
|
|
const foreignOriginResponse = await fetch(`${entry.origin}/api/bootstrap`, {
|
|
headers: {
|
|
[CAPABILITY_TOKEN_HEADER]: entry.token,
|
|
Origin: "https://attacker.example",
|
|
},
|
|
});
|
|
assert.equal(foreignOriginResponse.status, 403);
|
|
});
|