Files
decision/web/e2e/bulletins.spec.ts
T
YvvandClaude Opus 5.5 51c9a4771d v3 : vote par la chaîne, corrections de la relecture de 74f96be (interface)
- rien n'est parti (nœud muet avant la signature, refus, fenêtre fermée avant) :
  la page lâche le bulletin ; signé, ou montré en code Ğecko, il est gardé et
  le serveur le confirme sans la page ; le refus de la chaîne se dit (« chain ») ;
- le reçu : téléchargeable depuis la fenêtre de vote, et la page « Tes
  bulletins » (/bulletins) qui garde l'historique et vérifie un reçu dans le
  navigateur, sans le serveur (lib/receipt.ts, mêmes vecteurs que le moteur) ;
- le panier ne dit plus « Tes votes comptent dès l'envoi » quand le vote est fermé ;
- @noble/hashes déclaré (utilisé par e2e/chain-mock.mjs et le reçu) ;
- chaîne simulée : finalité, pages, filtres, nœud qui tombe ; contrôles C32, C33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:11:54 +01:00

138 lines
8.0 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.
type Entry = { id: string; status: string; comment: string };
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
async function unfold(page: Page, title: string) {
const unit = page.locator('section.text article.unit', { hasText: title });
await unit.hover();
await unit.getByRole('button', { name: 'Le vote' }).click();
return page.getByRole('region', { name: `Le vote de « ${title} »` });
}
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, 'Réciprocité');
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, 'Réciprocité')).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, 'Réciprocité');
await theirs.getByRole('button', { name: 'Pour', 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 : pour.')).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, 'Réciprocité')).locator('.voted')).toHaveText('Ton vote : pour · 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, 'Unicité du compte');
await fold.getByRole('button', { name: 'Pour', 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: 'for', title: 'Unicité du compte' })]);
// 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();
await expect(page.getByText('Unicité du compte : pour')).toBeVisible();
// One choice changed: it no longer holds.
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'against' })) };
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);
});