Files
decision/web/e2e/bulletins.spec.ts
T

148 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for
// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms
// it without the page; the receipt goes to the one who asked, who checks it here.
// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go
// to open proposals no other control reads, and only « contre »: nothing enters into
// force, nothing becomes well rated, the other controls find the text as they expect.
type Entry = { id: string; status: string; comment: string };
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
/** Unfolds the vote of an open proposal, from « Propositions en cours » (its sections folded: lot U). */
async function unfold(page: Page, title: string) {
const all = page.getByRole('button', { name: 'Tout déplier' });
if (await all.isVisible()) await all.click();
// By its title only: the excerpt of another proposal may name it.
await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click();
return page.getByRole('region', { name: `Le vote de « ${title} »` });
}
const REVOCATION = 'Document de révocation';
const NEVER_ALONE = 'Ne jamais certifier seul';
const answer = (page: Page, yes: boolean) =>
page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes);
test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => {
// The server reads the indexer every 10 seconds.
test.setTimeout(120_000);
await signIn(page, 'Rosa');
await wallet(page, [EVE]);
await page.goto(DOC);
const { control } = await chain(page);
const asked: string[] = [];
page.on('request', (r) => {
if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) {
asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`);
}
});
const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length;
const fold = await unfold(page, REVOCATION);
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
// The node does not answer before anything is signed: nothing left, the ballot is let go.
await control('node?down=1');
try {
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible();
await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']);
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0);
await expect.poll(() => mine(page)).toEqual([]);
} finally {
// The next controls need the node.
await control('node?down=0');
}
// The window closed while the wallet still asks: kept until it answers; refused, let go.
await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true));
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
await dialog.getByRole('button', { name: 'Fermer' }).click();
expect(deleted()).toBe(1);
await answer(page, false);
await expect.poll(deleted).toBe(2);
await expect.poll(() => mine(page)).toEqual([]);
// Signed once the window is closed: it may land, so it is kept; the server confirms it.
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
await dialog.getByRole('button', { name: 'Fermer' }).click();
await answer(page, true);
await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
expect(deleted()).toBe(2);
await page.goto(DOC);
await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
// Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server.
const ugo = await person(browser, 'Ugo');
await ugo.goto(DOC);
const theirs = await unfold(ugo, REVOCATION);
await theirs.getByRole('button', { name: 'Contre', exact: true }).click();
const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
await sheet.getByRole('button', { name: 'Fermer' }).click();
await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
const { node } = await chain(ugo);
const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' };
await ugo.request.post(node, {
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
});
await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
await ugo.goto(DOC);
await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
});
test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => {
test.setTimeout(90_000);
await signIn(page, 'Ines');
await wallet(page, [ALICE]);
await page.goto(DOC);
const fold = await unfold(page, NEVER_ALONE);
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]);
expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/);
const file = await download.path();
const receipt = JSON.parse(readFileSync(file, 'utf8'));
expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address });
expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/);
expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/);
expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]);
// The same ballot, in her ballots.
await dialog.getByRole('link', { name: 'Tes bulletins' }).click();
await expect(page).toHaveURL(/\/bulletins$/);
await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible();
const entry = page.locator('li.ballot');
await expect(entry).toHaveCount(1);
await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »");
await expect(entry).toContainText(receipt.comment);
// Checked here, in the browser, without the server.
await page.getByLabel('Fichier du reçu').setInputFiles(file);
await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible();
// Only what the seal covers is said verified: the version, its revision and the choice.
await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`);
// The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd).
await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).toBeVisible();
// One choice changed: it no longer holds.
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) };
await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
await page.getByRole('button', { name: 'Vérifier', exact: true }).click();
await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible();
await shot(page, '7-bulletins-recu');
// Nobody else gets it: neither in their ballots, nor by its address.
const other = await person(browser, 'Lou');
await other.goto('/bulletins');
await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible();
expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404);
});