mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
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>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Rūdolfs Ošiņš
parent
e930bfd397
commit
f90db0986a
@@ -0,0 +1,82 @@
|
|||||||
|
# 4. Untrusted releases and artifacts
|
||||||
|
|
||||||
|
Date: 2026-10-06
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Releases and their artifacts are COBs, so anyone can create a release in a
|
||||||
|
repo they replicate, and anyone can register an artifact on any release,
|
||||||
|
including a delegate's. An artifact is a file a user downloads and may run.
|
||||||
|
A release that sits in a well-known repo gets some of that repo's trust in
|
||||||
|
the eyes of a user who doesn't know what a delegate is. This makes it easy
|
||||||
|
to trick a user into downloading something malicious.
|
||||||
|
|
||||||
|
Trust is set at two levels:
|
||||||
|
|
||||||
|
- **Release:** its creator decides the title, tag and commit.
|
||||||
|
- **Artifact:** its author decides the bytes behind the CID.
|
||||||
|
|
||||||
|
The two can differ. A delegate's release can carry an artifact a
|
||||||
|
non-delegate registered, and a delegate can register an artifact on a
|
||||||
|
non-delegate's release.
|
||||||
|
|
||||||
|
The app, like radicle-explorer, splits releases and artifacts into two
|
||||||
|
disjoint scopes by author: Delegates and Others. In the code the scopes are
|
||||||
|
`trusted` and `untrusted`. The discussion is in the
|
||||||
|
[Artifacts topic](https://radicle.zulipchat.com/#narrow/channel/444463-Desktop/topic/Artifacts/with/624009755)
|
||||||
|
on Zulip.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Trust follows delegates.** A release or artifact is trusted when its
|
||||||
|
creator or author is a delegate of the repo, and untrusted otherwise. The
|
||||||
|
UI keeps the labels Delegates and Others, as "Untrusted" reads as too
|
||||||
|
alarming for content that is often harmless.
|
||||||
|
|
||||||
|
**Warn where untrusted content shows.** A short warning
|
||||||
|
(`UntrustedWarning.svelte`) says the content is not from a delegate and to
|
||||||
|
download it only if the user trusts its author. It stays small, so it
|
||||||
|
informs without alarm. It shows:
|
||||||
|
|
||||||
|
- above the Others releases list;
|
||||||
|
- on a release by a non-delegate, for the whole release;
|
||||||
|
- on a delegate's release, while it shows artifacts by non-delegates;
|
||||||
|
- in the download popover of an artifact by a non-delegate, whatever the
|
||||||
|
release, because that is where the user acts;
|
||||||
|
- in the browser tab of a delegate's artifact, while it lists links added
|
||||||
|
by non-delegates, as those downloads are not checked against the CID.
|
||||||
|
|
||||||
|
**Count only delegate attestations.** The attestation badge on an artifact
|
||||||
|
counts delegates only, as anyone can make keys that attest their own
|
||||||
|
artifact. The full list still shows every attestation.
|
||||||
|
|
||||||
|
**Keep untrusted content visible.** Others' releases and artifacts stay
|
||||||
|
listed by default, behind their own filter.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Hide Others by default**, behind a global setting. This protects users
|
||||||
|
best, and avoids showing links to malware at all, which is a reputation
|
||||||
|
risk a warning doesn't remove. It also hides legitimate contributions,
|
||||||
|
such as builds by a release manager who is not a delegate. It is still
|
||||||
|
open.
|
||||||
|
- **Rename Delegates to Official** or Others to Untrusted. This changes
|
||||||
|
only the wording and leaves the warning to a single label.
|
||||||
|
- **A list of trusted publishers in the repo identity**, like canonical ref
|
||||||
|
rules. This lets non-delegates publish trusted releases, but adds a
|
||||||
|
second trust model. Linking a release to a canonical annotated tag gives
|
||||||
|
part of this already, so canonical refs are the preferred path.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Users see a warning before they download an untrusted artifact, also when
|
||||||
|
it sits on a trusted release.
|
||||||
|
- A trusted artifact on an untrusted release still shows the release
|
||||||
|
warning, as its title, tag and commit are not trusted.
|
||||||
|
- Delegation is the only trust signal. Release managers who are not
|
||||||
|
delegates show as Others until releases can use canonical ref rules.
|
||||||
|
- radicle-explorer should show the same warnings so that both clients agree.
|
||||||
@@ -17,14 +17,13 @@
|
|||||||
import ExternalLink from "@app/components/ExternalLink.svelte";
|
import ExternalLink from "@app/components/ExternalLink.svelte";
|
||||||
import Icon from "@app/components/Icon.svelte";
|
import Icon from "@app/components/Icon.svelte";
|
||||||
import Popover from "@app/components/Popover.svelte";
|
import Popover from "@app/components/Popover.svelte";
|
||||||
|
import UntrustedWarning from "@app/components/UntrustedWarning.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
delegateIds: Set<string>;
|
delegateIds: Set<string>;
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
rid: string;
|
rid: string;
|
||||||
/// Whether the node already seeds this artifact, asked of the node rather
|
|
||||||
/// than assumed from what this component did.
|
|
||||||
seeding: boolean;
|
seeding: boolean;
|
||||||
onDownloaded: () => void;
|
onDownloaded: () => void;
|
||||||
}
|
}
|
||||||
@@ -40,8 +39,6 @@
|
|||||||
|
|
||||||
let activeTab: "app" | "cli" | "browser" = $state("app");
|
let activeTab: "app" | "cli" | "browser" = $state("app");
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
// Which transfer is running: a download writes to disk, a fetch only fills
|
|
||||||
// the node's store.
|
|
||||||
let running: "download" | "fetch" | undefined = $state();
|
let running: "download" | "fetch" | undefined = $state();
|
||||||
let progress: ArtifactProgress | undefined = $state();
|
let progress: ArtifactProgress | undefined = $state();
|
||||||
let downloadError: string | undefined = $state();
|
let downloadError: string | undefined = $state();
|
||||||
@@ -54,8 +51,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the outcome of a finished download once the popover closes, so a
|
|
||||||
// later visit starts fresh.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!expanded && !running) {
|
if (!expanded && !running) {
|
||||||
finished = undefined;
|
finished = undefined;
|
||||||
@@ -63,8 +58,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The node reports byte movement per CID, so a shared event channel
|
|
||||||
// is filtered down to this artifact.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Events only exist under Tauri, not against the test HTTP API.
|
// Events only exist under Tauri, not against the test HTTP API.
|
||||||
if (!window.__TAURI_INTERNALS__) {
|
if (!window.__TAURI_INTERNALS__) {
|
||||||
@@ -146,12 +139,13 @@
|
|||||||
const webLocations = $derived(
|
const webLocations = $derived(
|
||||||
releases.webLocations(artifact.locations, delegateIds),
|
releases.webLocations(artifact.locations, delegateIds),
|
||||||
);
|
);
|
||||||
// Any location at all can be fetched with the CLI, web locations included.
|
const authorTrusted = $derived(delegateIds.has(artifact.author.did));
|
||||||
|
const linksByOthers = $derived(
|
||||||
|
webLocations.some(l => !delegateIds.has(l.user.did)),
|
||||||
|
);
|
||||||
const downloadable = $derived(seeding || artifact.locations.length > 0);
|
const downloadable = $derived(seeding || artifact.locations.length > 0);
|
||||||
const downloadLabel = $derived(seeding ? "Save" : "Download");
|
const downloadLabel = $derived(seeding ? "Save" : "Download");
|
||||||
|
|
||||||
// The app and CLI tabs fetch through the artifact node, so they are only
|
|
||||||
// usable while it answers. The browser tab does not need it.
|
|
||||||
const nodeRunning = $derived($artifactNodeRunning);
|
const nodeRunning = $derived($artifactNodeRunning);
|
||||||
const START_COMMAND = "rad-artifact node start";
|
const START_COMMAND = "rad-artifact node start";
|
||||||
const command = $derived(
|
const command = $derived(
|
||||||
@@ -171,6 +165,9 @@
|
|||||||
.start-command {
|
.start-command {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.untrusted {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -265,6 +262,12 @@
|
|||||||
|
|
||||||
{#snippet popover()}
|
{#snippet popover()}
|
||||||
<div class="popover">
|
<div class="popover">
|
||||||
|
{#if !authorTrusted}
|
||||||
|
<div class="untrusted">
|
||||||
|
<UntrustedWarning
|
||||||
|
text="Not from a delegate. Only download if you trust the author." />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<Button
|
<Button
|
||||||
styleWidth="100%"
|
styleWidth="100%"
|
||||||
@@ -393,6 +396,12 @@
|
|||||||
<Icon name="warning" />
|
<Icon name="warning" />
|
||||||
These downloads are not checked against the CID.
|
These downloads are not checked against the CID.
|
||||||
</div>
|
</div>
|
||||||
|
{#if authorTrusted && linksByOthers}
|
||||||
|
<div class="untrusted">
|
||||||
|
<UntrustedWarning
|
||||||
|
text="Links without a delegate badge are not from a delegate. Only use them if you trust who added them." />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="locations">
|
<div class="locations">
|
||||||
{#each webLocations as location (`${location.user.did}:${location.url}`)}
|
{#each webLocations as location (`${location.user.did}:${location.url}`)}
|
||||||
<div class="location">
|
<div class="location">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@app/components/Icon.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: var(--txt-body-s-regular);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-feedback-warning-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="banner" role="note">
|
||||||
|
<span class="icon"><Icon name="warning" /></span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
+16
-21
@@ -22,18 +22,12 @@ export function releaseListScope(
|
|||||||
export interface ArtifactView {
|
export interface ArtifactView {
|
||||||
scope: ReleaseScope;
|
scope: ReleaseScope;
|
||||||
shown: Artifact[];
|
shown: Artifact[];
|
||||||
// Redacted artifacts within the current author scope.
|
|
||||||
redactedCount: number;
|
redactedCount: number;
|
||||||
// What each scope would show, so an artifact hidden as redacted never
|
|
||||||
// counts towards a scope.
|
|
||||||
counts: Record<ReleaseScope, number>;
|
counts: Record<ReleaseScope, number>;
|
||||||
// Filter only when both scopes hold something.
|
|
||||||
showFilters: boolean;
|
showFilters: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split a release's artifacts into two disjoint scopes by author. Show the
|
// Falls back to the other scope when the one asked for is empty.
|
||||||
// scope asked for unless it is empty, and hide artifacts redacted by their
|
|
||||||
// author or a delegate unless asked to show them.
|
|
||||||
export function artifactView(
|
export function artifactView(
|
||||||
artifacts: Artifact[],
|
artifacts: Artifact[],
|
||||||
delegates: Set<string>,
|
delegates: Set<string>,
|
||||||
@@ -70,7 +64,6 @@ export function redactedByDelegate(
|
|||||||
return artifact.redactions.some(r => delegates.has(r.user.did));
|
return artifact.redactions.some(r => delegates.has(r.user.did));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group locations by the contributing node, preserving order.
|
|
||||||
export function locationsByNode(
|
export function locationsByNode(
|
||||||
locations: Location[],
|
locations: Location[],
|
||||||
): { user: Author; urls: string[] }[] {
|
): { user: Author; urls: string[] }[] {
|
||||||
@@ -83,9 +76,7 @@ export function locationsByNode(
|
|||||||
return [...groups.values()];
|
return [...groups.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order a node-keyed list so delegates come first. The backend returns these
|
// The backend sorts by DID, which means nothing to a reader.
|
||||||
// sorted by DID (arbitrary to a reader); sort is stable, so the original
|
|
||||||
// order is preserved within each group.
|
|
||||||
export function delegatesFirst<T>(
|
export function delegatesFirst<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
did: (item: T) => string,
|
did: (item: T) => string,
|
||||||
@@ -96,9 +87,6 @@ export function delegatesFirst<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locations a browser can open, as opposed to ones fetched from a seeder
|
|
||||||
// over the radicle-artifact protocol. Delegate locations come first: they
|
|
||||||
// are the ones a reader can trust most.
|
|
||||||
export function webLocations(
|
export function webLocations(
|
||||||
locations: Location[],
|
locations: Location[],
|
||||||
delegates: Set<string>,
|
delegates: Set<string>,
|
||||||
@@ -119,8 +107,6 @@ export function attestedBy(
|
|||||||
return attestations.filter(a => delegates.has(a.did)).length;
|
return attestations.filter(a => delegates.has(a.did)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only metadata by the author or a delegate is shown, so only they get an
|
|
||||||
// editor.
|
|
||||||
export function canEditMetadata(
|
export function canEditMetadata(
|
||||||
artifact: Artifact,
|
artifact: Artifact,
|
||||||
ownDid: string,
|
ownDid: string,
|
||||||
@@ -140,9 +126,7 @@ export function canAttest(artifact: Artifact, ownDid: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The COB stores values as free-form JSON. Text that parses as JSON is sent
|
// Text that parses as JSON is sent as that value, so numbers round-trip.
|
||||||
// as that value, so numbers and booleans round-trip; anything else is sent
|
|
||||||
// as a plain string.
|
|
||||||
export function parseMetadataValue(input: string): unknown {
|
export function parseMetadataValue(input: string): unknown {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(input);
|
return JSON.parse(input);
|
||||||
@@ -155,8 +139,6 @@ export function displayMetadataValue(value: unknown): string {
|
|||||||
return typeof value === "string" ? value : JSON.stringify(value);
|
return typeof value === "string" ? value : JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commits whose SHA starts with, or whose summary contains, the query. A
|
|
||||||
// pasted commit outside the loaded history still shows as a choice.
|
|
||||||
export function matchCommits(
|
export function matchCommits(
|
||||||
commits: Commit[],
|
commits: Commit[],
|
||||||
query: string,
|
query: string,
|
||||||
@@ -179,3 +161,16 @@ export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] {
|
|||||||
.map(([name, tag]) => ({ name, tag }))
|
.map(([name, tag]) => ({ name, tag }))
|
||||||
.sort((a, b) => b.tag.timestamp - a.tag.timestamp);
|
.sort((a, b) => b.tag.timestamp - a.tag.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A release by a non-delegate is untrusted as a whole. On a delegate's
|
||||||
|
// release, only the artifacts registered by others are.
|
||||||
|
export function releaseWarning(
|
||||||
|
creatorDid: string,
|
||||||
|
delegates: Set<string>,
|
||||||
|
artifactScope: ReleaseScope,
|
||||||
|
): "release" | "artifacts" | undefined {
|
||||||
|
if (!delegates.has(creatorDid)) {
|
||||||
|
return "release";
|
||||||
|
}
|
||||||
|
return artifactScope === "untrusted" ? "artifacts" : undefined;
|
||||||
|
}
|
||||||
|
|||||||
+39
-1284
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
import ReleaseTeaser from "@app/components/ReleaseTeaser.svelte";
|
import ReleaseTeaser from "@app/components/ReleaseTeaser.svelte";
|
||||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||||
import Topbar from "@app/components/Topbar.svelte";
|
import Topbar from "@app/components/Topbar.svelte";
|
||||||
|
import UntrustedWarning from "@app/components/UntrustedWarning.svelte";
|
||||||
import VirtualList from "@app/components/VirtualList.svelte";
|
import VirtualList from "@app/components/VirtualList.svelte";
|
||||||
import CreateReleaseModal from "@app/modals/CreateRelease.svelte";
|
import CreateReleaseModal from "@app/modals/CreateRelease.svelte";
|
||||||
|
|
||||||
@@ -85,6 +86,10 @@
|
|||||||
.filter .global-counter-badge {
|
.filter .global-counter-badge {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.warning {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
border-bottom: 1px solid var(--color-border-subtle);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if scope === "untrusted"}
|
||||||
|
<div class="warning">
|
||||||
|
<UntrustedWarning
|
||||||
|
text="Not from delegates. Only download from authors you trust." />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<ScrollArea style="height: 100%; min-width: 0;">
|
<ScrollArea style="height: 100%; min-width: 0;">
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={list.items}
|
items={list.items}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
useBackend,
|
useBackend,
|
||||||
} from "@tests/support/fixtures.js";
|
} from "@tests/support/fixtures.js";
|
||||||
|
|
||||||
|
const OTHER_DID = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5";
|
||||||
|
|
||||||
test("the commit picker lists commits and rejects unknown SHAs", async ({
|
test("the commit picker lists commits and rejects unknown SHAs", async ({
|
||||||
page,
|
page,
|
||||||
peer,
|
peer,
|
||||||
@@ -222,9 +224,7 @@ test("a location added by someone else cannot be removed", async ({
|
|||||||
const release = await (await route.fetch()).json();
|
const release = await (await route.fetch()).json();
|
||||||
for (const artifact of release.artifacts) {
|
for (const artifact of release.artifacts) {
|
||||||
artifact.locations.push({
|
artifact.locations.push({
|
||||||
user: {
|
user: { did: OTHER_DID },
|
||||||
did: "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
|
|
||||||
},
|
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,6 +270,83 @@ test("removing your node's location while seeding stops seeding", async ({
|
|||||||
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
|
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 }) => {
|
test("metadata can be added, edited and removed", async ({ page, peer }) => {
|
||||||
const rid = await fillRelease(page, peer);
|
const rid = await fillRelease(page, peer);
|
||||||
await page.getByRole("button", { name: "Create release" }).click();
|
await page.getByRole("button", { name: "Create release" }).click();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
parseMetadataValue,
|
parseMetadataValue,
|
||||||
redactedByDelegate,
|
redactedByDelegate,
|
||||||
releaseListScope,
|
releaseListScope,
|
||||||
|
releaseWarning,
|
||||||
webLocations,
|
webLocations,
|
||||||
} from "@app/lib/releases";
|
} from "@app/lib/releases";
|
||||||
|
|
||||||
@@ -307,3 +308,18 @@ describe("canonicalTags", () => {
|
|||||||
).toBe(canonical);
|
).toBe(canonical);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("releaseWarning", () => {
|
||||||
|
test("warns about a whole release by a non-delegate", () => {
|
||||||
|
expect(releaseWarning(them, delegates, "trusted")).toBe("release");
|
||||||
|
expect(releaseWarning(them, delegates, "untrusted")).toBe("release");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warns about others' artifacts on a delegate's release", () => {
|
||||||
|
expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not warn about a delegate's release and artifacts", () => {
|
||||||
|
expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user