Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
File renamed without changes.
13 changes: 9 additions & 4 deletions components/delegate/RegistrationIntroCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ export function RegistrationIntroCard() {
</li>
</ul>
</div>
{/* The election route is deliberately not in the main nav — it is only
relevant to the handful of addresses standing in an election, and
this is the page they already come to for their own details. */}
{/* Deliberately not in the main nav — it is only relevant to the
handful of addresses standing in an election, and this is the page
they already come to for their own details. It also cannot live at
/elections — next.config.mjs permanently redirects that to
/security-council for old bookmarks. */}
<p>
Standing in a Security Council election?{" "}
<Link href="/elections" className="text-primary hover:underline">
<Link
href="/profile/candidate"
className="text-primary hover:underline"
>
Publish your candidate profile
</Link>
.
Expand Down
24 changes: 15 additions & 9 deletions components/drafts/DraftList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,21 @@ function DraftRow({ draft }: { draft: DraftSummary }) {
</>
) : null}

<Button
size="sm"
variant="ghost"
data-testid="delete-draft"
onClick={() => setConfirming("delete")}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
{/* Editing, publishing and deleting all require status `draft`:
the API answers 409 not_editable once a draft is published,
because the share link has to keep resolving to what reviewers
were shown. So a published draft offers none of them. */}
{isEditable ? (
<Button
size="sm"
variant="ghost"
data-testid="delete-draft"
onClick={() => setConfirming("delete")}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
) : null}
</div>
)}
</CardContent>
Expand Down
21 changes: 17 additions & 4 deletions components/drafts/SharedDraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/Input";
import { Label } from "@/components/ui/Label";
import { Skeleton } from "@/components/ui/Skeleton";
import { useMarkSubmitted, useSharedDraft } from "@/hooks/use-drafts";
import { useSiwe } from "@/hooks/use-siwe";
import {
getProposalPreviewRehypePlugins,
getProposalPreviewRemarkPlugins,
Expand Down Expand Up @@ -189,11 +190,13 @@ function SubmittedCard({ draft }: { draft: Draft }) {
/**
* Attaches the transaction that put this draft on chain.
*
* Unauthenticated, matching the route: whoever submits a proposal is often not
* its author, and requiring the author to come back and record it would leave
* most drafts permanently marked unsubmitted.
* Reading a shared draft needs no session, but recording a submission does — the
* route is behind `requireSession`. It does not require *authorship*, though, so
* the delegate who actually submitted the proposal can record it without the
* author coming back.
*/
function MarkSubmittedForm({ slug }: { slug: string }) {
const { isSignedIn } = useSiwe();
const { markSubmitted, isSubmitting, error } = useMarkSubmitted(slug);
const [transactionHash, setTransactionHash] = useState("");
const [governorAddress, setGovernorAddress] = useState("");
Expand Down Expand Up @@ -231,6 +234,16 @@ function MarkSubmittedForm({ slug }: { slug: string }) {
holding this draft can follow it.
</p>

{!isSignedIn ? (
<p
className="text-sm text-amber-400"
data-testid="submit-needs-signin"
>
Sign in with your wallet to record a submission. You do not need to
be the draft&apos;s author.
</p>
) : null}

<div className="space-y-2">
<Label htmlFor="draft-tx-hash">Transaction hash</Label>
<Input
Expand Down Expand Up @@ -284,7 +297,7 @@ function MarkSubmittedForm({ slug }: { slug: string }) {
<Button
data-testid="mark-submitted"
onClick={submit}
disabled={!isValid || isSubmitting}
disabled={!isSignedIn || !isValid || isSubmitting}
>
{isSubmitting ? "Recording…" : "Mark as submitted"}
</Button>
Expand Down
73 changes: 73 additions & 0 deletions e2e/avatar-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";

import { signedInPage } from "./fixtures/session";

// 1x1 transparent PNG — small enough to be well under the 2MB cap, and real PNG
// bytes so it survives the server's content sniffing.
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);

// Covers POST /api/me/avatar-intent's 401 and 403 branches, which the happy path
// in profile.spec.ts cannot reach.
//
// The 429 branch is deliberately NOT covered. Reaching it means exhausting the
// real per-address limiter with a key that *is* a delegate, which would leave
// that key rate-limited for the rest of the run and make the suite's result
// depend on test order. It stays covered by the indexer's own unit tests.
test.describe("avatar upload gate", () => {
test("refuses an address with no delegated voting power (403)", async ({
browser,
}) => {
// The `noVotingPower` key holds ARB but has delegated all of it away, so the
// indexer's delegatedVotesCount is 0. It is the only dev key that can take
// this branch — every other funded key is a delegate.
const page = await signedInPage(browser, "noVotingPower");

await page.getByTestId("profile-avatar-input").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer: PNG,
});

await expect(
page.getByText(/Only delegates with voting power can upload an avatar/)
).toBeVisible();
// Rejected before storage, so nothing is attached to the profile.
await expect(page.getByTestId("profile-avatar-preview")).toHaveCount(0);
});

test("authorizes before inspecting the file at all", async ({ browser }) => {
const page = await signedInPage(browser, "noVotingPower");

// A plain text file would be a 400 for a delegate. This key is not one, so
// the delegate check is what surfaces — proving the gate runs first and an
// unauthorized caller cannot make the server read its upload.
const res = await page.request.post("/api/profile/avatar", {
multipart: {
file: {
name: "not-an-image.txt",
mimeType: "text/plain",
buffer: Buffer.from("definitely not a png"),
},
},
});

expect(res.status()).toBe(403);
});

test("requires a session (401)", async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();

const res = await page.request.post("/api/profile/avatar", {
multipart: {
file: { name: "avatar.png", mimeType: "image/png", buffer: PNG },
},
});

expect(res.status()).toBe(401);
await context.close();
});
});
84 changes: 84 additions & 0 deletions e2e/candidates.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from "@playwright/test";

import { signedInPage } from "./fixtures/session";
import { DEV_WALLETS } from "./fixtures/wallets";

// Covers GET /api/elections, GET/PUT /api/me/candidate-profile/:electionId, and
// the public GET /api/elections/:id/candidate-profiles/:address.
//
// Candidate profiles are per-election, so every write needs an election that has
// actually been indexed. The testnode bakes no election fixture (the indexer only
// ever writes phase CONTENDER_SUBMISSION, so a synthetic completed election would
// misreport itself — see the plan's dropped items), which means these tests skip
// on a bare stack rather than passing vacuously.
test.describe("candidate profiles", () => {
test("shows the empty state when no election is indexed", async ({
browser,
}) => {
const page = await signedInPage(
browser,
"candidates",
"/profile/candidate"
);
const res = await page.request.get("/api/governance-indexer/api/elections");
const { elections } = (await res.json()) as { elections: unknown[] };

test.skip(
elections.length > 0,
"an election is indexed, so the empty state is not the case under test"
);

await expect(
page.getByText(/No elections have been indexed yet/)
).toBeVisible();
});

test.describe("with an indexed election", () => {
test("saves a version and publishes it to the contender page", async ({
browser,
}) => {
const page = await signedInPage(
browser,
"candidates",
"/profile/candidate"
);
const res = await page.request.get(
"/api/governance-indexer/api/elections"
);
const { elections } = (await res.json()) as { elections: unknown[] };

test.skip(
elections.length === 0,
"needs an election indexed on the local stack"
);

const name = `E2E Candidate ${Date.now()}`;
await expect(page.getByTestId("candidate-name")).toBeVisible();
await page.getByTestId("candidate-name").fill(name);
await page.getByTestId("candidate-country").fill("Portugal");
// Stored as a bare handle; the contender page turns it into a URL.
await page.getByTestId("candidate-twitter").fill("e2e_candidate");
await page
.getByTestId("candidate-skills")
.fill("Solidity, Incident response");
await page.getByTestId("candidate-motivation").fill("Motivated by e2e.");

await page.getByTestId("candidate-save").click();
// Writes are append-only, so the toast names the version it minted.
await expect(page.getByText(/Saved as version \d+/)).toBeVisible();

// A reload re-reads it from the indexer rather than local state.
await page.reload();
await expect(page.getByTestId("candidate-name")).toHaveValue(name);

// And it reaches the public contender page, where it is labelled as
// self-published because this address is not in the candidate registry.
await page.goto(
`/security-council/contender/${DEV_WALLETS.candidates.address}`
);
await expect(page.getByTestId("candidate-unverified")).toBeVisible();
await expect(page.getByText(name)).toBeVisible();
await expect(page.getByText("Incident response")).toBeVisible();
});
});
});
Loading