Files
radicle-explorer/tests/e2e/repo/createRelease.spec.ts
T
Daniel NormanandRūdolfs Ošiņš f90db0986a Warn about releases and artifacts by others
- Add an UntrustedWarning banner
- Warn on the Others releases list
- Warn on a release by a non-delegate
- Warn on artifacts by non-delegates on a delegate release
- Warn in the download popover for a non-delegate artifact
- Add E2E tests for both release cases

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
2026-10-08 18:21:48 +02:00

571 lines
21 KiB
TypeScript

import type { Page } from "@playwright/test";
import type { PeerManager, RadiclePeer } from "@tests/support/peerManager.js";
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
import {
clone,
createCollaborators,
createProject,
} from "@tests/support/collaboration.js";
import {
expect,
goto,
reload,
test,
useBackend,
} from "@tests/support/fixtures.js";
const OTHER_DID = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5";
test("the commit picker lists commits and rejects unknown SHAs", async ({
page,
peer,
}) => {
const { rid, repoFolder } = await createProject(peer);
const { stdout: head } = await peer.git(["rev-parse", "main"], {
cwd: repoFolder,
});
await page.goto(`/repos/${rid}/releases`);
await page.getByRole("button", { name: "New release" }).click();
const input = page.getByPlaceholder("Pick a commit or paste a SHA");
await input.click();
await page.getByText("Add notes").click();
await expect(input).toHaveValue(head.trim());
await input.fill("cafecafecafecafecafecafecafecafecafecafe");
await expect(
page.getByText("Commit cafecaf is not in this repo."),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Create release" }),
).toBeDisabled();
});
// Fills the New release modal with one artifact, without submitting it.
async function fillRelease(page: Page, peer: RadiclePeer) {
const { rid, repoFolder } = await createProject(peer);
const file = Path.join(repoFolder, "build.tar");
await Fs.writeFile(file, "artifact bytes");
// The native file dialog only exists under Tauri.
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [file] }),
);
await page.goto(`/repos/${rid}/releases`);
await page.getByRole("button", { name: "New release" }).click();
await page.getByPlaceholder("Pick a commit or paste a SHA").click();
await page.getByText("Add notes").click();
await page.getByRole("button", { name: "Choose files" }).click();
return rid;
}
test("creating a release opens it and lists it", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
// The test peer runs no artifact node, so there is nothing to seed with.
await expect(
page.getByTitle("Your artifact node is not running"),
).toBeDisabled();
await goto(page, `/repos/${rid}/releases`);
await expect(page.getByText("Add notes")).toBeVisible();
});
test("a release whose seeding failed still opens", async ({ page, peer }) => {
await page.route(/\/artifact_node_running$/, route =>
route.fulfill({ json: true }),
);
await page.route(/\/seed_artifact$/, route =>
route.fulfill({
status: 500,
json: { code: "UnknownError", message: "seeding failed" },
}),
);
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(
page.getByText("Registered, but 1 artifact could not be seeded."),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeEnabled();
await page.getByRole("button", { name: "Open release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
});
test("a release can be deleted", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.getByRole("button", { name: "Delete" }).click();
await page
.locator("[data-modal-content]")
.getByRole("button", { name: "Delete" })
.click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases$`));
await expect(page.getByText("No releases")).toBeVisible();
});
test("re-registering an artifact only signs what changes", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
const modal = page.locator("[data-modal-content]");
// The same bytes under the same name change nothing.
await page.getByRole("button", { name: "Register artifacts" }).click();
await page.getByRole("button", { name: "Files…" }).click();
await expect(modal.getByText("Nothing will be registered.")).toBeVisible();
await expect(
modal.getByRole("button", { name: "icon-plus Register" }),
).toBeHidden();
await modal.getByRole("button", { name: "Close", exact: true }).click();
// The same bytes under a new name rename your own artifact.
const renamed = Path.join(peer.checkoutPath, "renamed.tar");
await Fs.writeFile(renamed, "artifact bytes");
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [renamed] }),
);
await page.getByRole("button", { name: "Register artifacts" }).click();
await page.getByRole("button", { name: "Files…" }).click();
await expect(modal.getByText("renames “build.tar”")).toBeVisible();
await modal.getByRole("button", { name: "icon-plus Update" }).click();
await expect(
page.getByRole("button", { name: /renamed\.tar/ }),
).toBeVisible();
});
test("picks with the same contents are registered once", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
const copy = Path.join(peer.checkoutPath, "copy.tar");
await Fs.writeFile(copy, "artifact bytes");
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [copy] }),
);
await page.getByRole("button", { name: "Choose files" }).click();
await expect(page.getByText("same as “build.tar”")).toBeVisible();
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await expect(page.getByRole("button", { name: /build\.tar/ })).toBeVisible();
// Two new picks with the same contents in one go.
const first = Path.join(peer.checkoutPath, "a.tar");
const second = Path.join(peer.checkoutPath, "b.tar");
await Fs.writeFile(first, "other bytes");
await Fs.writeFile(second, "other bytes");
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [first, second] }),
);
const modal = page.locator("[data-modal-content]");
await page.getByRole("button", { name: "Register artifacts" }).click();
await page.getByRole("button", { name: "Files…" }).click();
await expect(modal.getByText("same as “a.tar” · skipped")).toBeVisible();
await modal.getByRole("button", { name: "icon-plus Register" }).click();
await expect(modal).toBeHidden();
await expect(page.getByRole("button", { name: /a\.tar/ })).toBeVisible();
await expect(page.getByRole("button", { name: /b\.tar/ })).toBeHidden();
});
test("a location can be added and removed", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.getByRole("button", { name: /build\.tar/ }).click();
const add = page.getByTitle("Add a URL others can download");
const input = page.getByPlaceholder("https://…");
await add.click();
await input.fill("iroh://abc");
await input.press("Enter");
await expect(
page.getByText("This is not a URL peers can fetch from."),
).toBeVisible();
const url = "https://example.com/build.tar";
await input.fill(url);
await input.press("Enter");
await expect(page.getByText(url)).toBeVisible();
await page.getByTitle("Remove location").click();
await page.getByRole("button", { name: "icon-trash Remove" }).click();
await expect(page.getByText(url)).toBeHidden();
await expect(page.getByText("No locations")).toBeVisible();
});
test("a location added by someone else cannot be removed", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
// The test peers run no artifact node, so another user's location is
// injected into the release the view reads.
const url = "https://example.com/theirs.tar";
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.locations.push({
user: { did: OTHER_DID },
url,
});
}
await route.fulfill({ json: release });
});
await reload(page);
await page.getByRole("button", { name: /build\.tar/ }).click();
await expect(page.getByText(url)).toBeVisible();
await expect(page.getByTitle("Remove location")).toBeHidden();
});
test("removing your node's location while seeding stops seeding", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.getByRole("button", { name: /build\.tar/ }).click();
await page.getByTitle("Add a URL others can download").click();
await page.getByPlaceholder("https://…").fill("radiroh://");
const added = page.waitForRequest(/\/add_artifact_location$/);
await page.getByPlaceholder("https://…").press("Enter");
const { cid, releaseId } = (await added).postDataJSON();
await expect(page.getByText("radiroh://")).toBeVisible();
// The test peers run no artifact node, so it is made to report the
// artifact as seeded and to accept the unseed.
await page.route(/\/seeded_artifacts$/, route =>
route.fulfill({ json: [cid] }),
);
const unseed = page.waitForRequest(/\/unseed_artifact$/);
await page.route(/\/unseed_artifact$/, route =>
route.fulfill({ json: null }),
);
await reload(page);
await page.getByRole("button", { name: /build\.tar/ }).click();
await page.getByTitle("Remove location").click();
await expect(page.getByText("Unseed this artifact?")).toBeVisible();
await page.getByRole("button", { name: "icon-trash Unseed" }).click();
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
});
test("a release by a non-delegate shows a warning", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await expect(page.getByRole("note")).toBeHidden();
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
release.creator.did = OTHER_DID;
await route.fulfill({ json: release });
});
await reload(page);
await expect(
page.getByRole("note").getByText("Not from a delegate."),
).toBeVisible();
});
test("an artifact by a non-delegate shows a warning", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
// A location makes the download button usable.
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.author.did = OTHER_DID;
artifact.locations.push({
user: { did: OTHER_DID },
url: "https://example.com/theirs.tar",
});
}
await route.fulfill({ json: release });
});
await reload(page);
await expect(
page.getByRole("note").getByText("Not from delegates."),
).toBeVisible();
await page.getByRole("button", { name: "icon-download Download" }).click();
await expect(page.getByText("Not from a delegate.")).toBeVisible();
});
test("a link by a non-delegate on a delegate's artifact shows a warning", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.route(/\/release_by_id$/, async route => {
const release = await (await route.fetch()).json();
for (const artifact of release.artifacts) {
artifact.locations.push({
user: { did: OTHER_DID },
url: "https://example.com/theirs.tar",
});
}
await route.fulfill({ json: release });
});
await reload(page);
await page.getByRole("button", { name: "icon-download Download" }).click();
await expect(page.getByText("Not from a delegate.")).toBeHidden();
await page
.getByRole("button", { name: "icon-open-external Browser" })
.click();
await expect(
page.getByRole("note").getByText("Links without a delegate badge"),
).toBeVisible();
});
test("metadata can be added, edited and removed", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.getByRole("button", { name: /build\.tar/ }).click();
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
await metadata.getByRole("button", { name: "Add" }).click();
await metadata.getByPlaceholder("Key").fill("arch");
await metadata.getByPlaceholder("Value").fill("x86_64");
await metadata.getByPlaceholder("Value").press("Enter");
await expect(metadata.getByText("x86_64")).toBeVisible();
await reload(page);
await page.getByRole("button", { name: /build\.tar/ }).click();
await expect(metadata.getByText("x86_64")).toBeVisible();
await metadata.getByTitle("Edit").click();
await metadata.getByPlaceholder("Value").fill("aarch64");
await metadata.getByRole("button", { name: "Save" }).click();
await expect(metadata.getByText("aarch64")).toBeVisible();
await metadata.getByTitle("Remove").click();
await page.getByRole("button", { name: "icon-trash Remove" }).click();
await expect(metadata.getByText("No metadata")).toBeVisible();
});
test("an artifact can be redacted", async ({ page, peer }) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
await page.getByRole("button", { name: /build\.tar/ }).click();
await page.getByRole("button", { name: "Redact" }).click();
const modal = page.locator("[data-modal-content]");
await modal
.getByPlaceholder("Why should this no longer be used?")
.fill("bad");
await modal.getByRole("button", { name: "Redact" }).click();
await expect(modal).toBeHidden();
// A redaction by the author hides the artifact by default.
await expect(page.getByText("No artifacts")).toBeVisible();
await page.getByRole("button", { name: /Show redacted/ }).click();
await page.getByRole("button", { name: /build\.tar/ }).click();
await expect(page.getByText("bad", { exact: true })).toBeVisible();
});
// Calls a command on the peer's test backend directly, bypassing the app.
function backend(peer: RadiclePeer) {
return async (cmd: string, args: object) => {
const { hostname, port } = peer.httpdBaseUrl;
const response = await fetch(`http://${hostname}:${port}/${cmd}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
});
expect(response.ok).toBe(true);
return response.json();
};
}
test("opening another release starts from a fresh page", async ({
page,
peer,
}) => {
const { rid, repoFolder } = await createProject(peer);
const file = Path.join(repoFolder, "build.tar");
await Fs.writeFile(file, "artifact bytes");
const call = backend(peer);
const releaseIds: string[] = [];
// Both releases carry the same bytes, so their artifacts share a CID.
for (const revision of ["main~1", "main"]) {
const { stdout: oid } = await peer.git(["rev-parse", revision], {
cwd: repoFolder,
});
const releaseId: string = await call("create_or_open_release", {
rid,
oid: oid.trim(),
tag: null,
});
const { cid, sizeBytes } = await call("compute_artifact_cid", {
path: file,
});
await call("register_artifact", {
rid,
releaseId,
cid,
name: "build.tar",
sizeBytes,
});
releaseIds.push(releaseId);
}
const [first, second] = releaseIds;
await page.goto(`/repos/${rid}/releases/${first}`);
await page.getByRole("button", { name: /build\.tar/ }).click();
const attestations = page
.locator(".section")
.filter({ hasText: /^Attestations/ });
await expect(attestations).toBeVisible();
// A link to another release, as a mention renders one, navigates in place.
await page.evaluate(href => {
const link = document.createElement("a");
link.href = href;
link.textContent = "Next release";
document.body.append(link);
}, `/repos/${rid}/releases/${second}`);
await page.getByRole("link", { name: "Next release" }).click();
await expect(page.locator(".title").getByText("Add notes")).toBeVisible();
await expect(attestations).toBeHidden();
});
test("a failed refresh after registering is not reported as a failure", async ({
page,
peer,
}) => {
const rid = await fillRelease(page, peer);
await page.getByRole("button", { name: "Create release" }).click();
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
const other = Path.join(peer.checkoutPath, "other.tar");
await Fs.writeFile(other, "other bytes");
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [other] }),
);
await page.route(/\/release_by_id$/, route =>
route.fulfill({
status: 500,
json: { code: "UnknownError", message: "refresh failed" },
}),
);
const modal = page.locator("[data-modal-content]");
await page.getByRole("button", { name: "Register artifacts" }).click();
await page.getByRole("button", { name: "Files…" }).click();
await modal.getByRole("button", { name: "icon-plus Register" }).click();
await expect(modal).toBeHidden();
await expect(page.getByText("Registering failed.")).toBeHidden();
});
// Bob publishes an artifact; the app runs as eve, who isn't a delegate.
async function openBobsArtifact(page: Page, peerManager: PeerManager) {
const { bob, eve } = await createCollaborators(peerManager);
const { rid, repoFolder } = await createProject(bob);
const { stdout: head } = await bob.git(["rev-parse", "main"], {
cwd: repoFolder,
});
const file = Path.join(bob.checkoutPath, "build.tar");
await Fs.writeFile(file, "artifact bytes");
await bob.startHttpd();
const call = backend(bob);
const releaseId: string = await call("create_or_open_release", {
rid,
oid: head.trim(),
tag: null,
});
const { cid, sizeBytes } = await call("compute_artifact_cid", { path: file });
await call("register_artifact", {
rid,
releaseId,
cid,
name: "build.tar",
sizeBytes,
});
await clone(eve, rid);
await eve.startHttpd();
await useBackend(page, eve);
await page.goto(`/repos/${rid}/releases/${releaseId}`);
await page.getByRole("button", { name: /build\.tar/ }).click();
return eve;
}
test("a non-delegate can attest an artifact by reproducing it", async ({
page,
peerManager,
}) => {
const eve = await openBobsArtifact(page, peerManager);
const other = Path.join(eve.checkoutPath, "other.tar");
const same = Path.join(eve.checkoutPath, "same.tar");
await Fs.writeFile(other, "other bytes");
await Fs.writeFile(same, "artifact bytes");
const attestations = page
.locator(".section")
.filter({ hasText: /^Attestations/ });
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [other] }),
);
await attestations.getByRole("button", { name: /Attest/ }).click();
await expect(
page.getByText("Your build has a different CID, so it was not attested."),
).toBeVisible();
await page.route(/\/pick_artifact_files$/, route =>
route.fulfill({ json: [same] }),
);
await attestations.getByRole("button", { name: /Attest/ }).click();
await expect(attestations.getByText("eve")).toBeVisible();
await expect(
attestations.getByRole("button", { name: /Attest/ }),
).toBeHidden();
});
test("a non-delegate cannot redact, edit or delete someone else's release", async ({
page,
peerManager,
}) => {
await openBobsArtifact(page, peerManager);
await expect(
page.locator(".section").filter({ hasText: /^Attestations/ }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Redact" })).toBeHidden();
await expect(page.getByRole("button", { name: "Delete" })).toBeHidden();
const metadata = page.locator(".section").filter({ hasText: /^Metadata/ });
await expect(metadata.getByRole("button", { name: "Add" })).toBeHidden();
});