forked from yvv/decision
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>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
});
|
||||
+37
-8
@@ -6,11 +6,15 @@
|
||||
// each one is included in a new block, as the transfer and the remark of the
|
||||
// real `utility.batchAll` would be;
|
||||
// - `/v1/graphql`, the « indexer »: answers the server's query
|
||||
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
|
||||
// `squidStatus { height } txComments(condition: {hash}, filter, first, after)`
|
||||
// in the shape of indexer.duniter.org: the last final block (here, every
|
||||
// block is final), the comments in the order of the chain, filtered (author,
|
||||
// or a transfer to an account of at least an amount) and served page by page.
|
||||
//
|
||||
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
|
||||
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
|
||||
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
|
||||
// the indexer, as an indexer behind the chain), `show` (stop hiding), `release`
|
||||
// (the indexer catches up), `silent` (the indexer stops answering), `node`
|
||||
// (`?down=1`: the node stops answering, before anything is sent) and `state`.
|
||||
//
|
||||
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
|
||||
|
||||
@@ -27,7 +31,7 @@ const IDENTITIES = {
|
||||
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
|
||||
};
|
||||
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 };
|
||||
|
||||
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
|
||||
@@ -49,6 +53,20 @@ async function read(req) {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the real indexer's `filter` keeps, for the two filters the server asks. */
|
||||
function kept(c, filter) {
|
||||
if (!filter) return true;
|
||||
const author = filter.authorId?.equalTo;
|
||||
if (author && c.author.id !== author) return false;
|
||||
const some = filter.transfersByCommentId?.some;
|
||||
if (!some) return true;
|
||||
const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0');
|
||||
return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min);
|
||||
}
|
||||
|
||||
/** The size of a page: `first: N` in the query, as the server writes it. */
|
||||
const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100);
|
||||
|
||||
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
|
||||
function include(fields) {
|
||||
state.block += 1;
|
||||
@@ -70,7 +88,9 @@ const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
|
||||
|
||||
if (url.pathname === '/rpc' && req.method === 'POST') {
|
||||
if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true);
|
||||
const body = await read(req);
|
||||
if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true);
|
||||
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
|
||||
let fields;
|
||||
try {
|
||||
@@ -90,11 +110,19 @@ const server = createServer(async (req, res) => {
|
||||
const hash = body.variables?.hash;
|
||||
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
|
||||
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
|
||||
const nodes = state.comments
|
||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
|
||||
.sort((a, b) => a.blockNumber - b.blockNumber)
|
||||
const all = state.comments
|
||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter))
|
||||
.sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id))
|
||||
.map(({ hidden: _, ...c }) => c);
|
||||
return send(res, 200, { data: { txComments: { nodes } } });
|
||||
const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0);
|
||||
const nodes = all.slice(start, start + pageSize(String(body.query)));
|
||||
const end = start + nodes.length;
|
||||
return send(res, 200, {
|
||||
data: {
|
||||
squidStatus: { height: state.block },
|
||||
txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
|
||||
@@ -105,6 +133,7 @@ const server = createServer(async (req, res) => {
|
||||
state.hiding = false;
|
||||
for (const c of state.comments) c.hidden = false;
|
||||
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
|
||||
else if (what === 'node') state.nodeDown = url.searchParams.get('down') === '1';
|
||||
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
|
||||
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
|
||||
}
|
||||
|
||||
+16
-3
@@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
||||
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
|
||||
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
|
||||
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
|
||||
export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' };
|
||||
|
||||
export async function signIn(page: Page, name: string, admin = false) {
|
||||
const r = await page.request.post('/api/auth/dev', {
|
||||
@@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro
|
||||
/**
|
||||
* A fake wallet extension, as duniter-connect offers itself to pages
|
||||
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
|
||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
|
||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse;
|
||||
* `window.__ldHold = true` makes it wait, as a person who has not answered yet,
|
||||
* until `window.__ldAnswer(true | false)` (sign, or refuse).
|
||||
* It signs nothing real: the page, in test mode, sends to the simulated node.
|
||||
*/
|
||||
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
|
||||
await page.addInitScript((list) => {
|
||||
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
|
||||
const w = window as unknown as Record<string, unknown> & {
|
||||
__ldSigned: unknown[];
|
||||
__ldRefuse?: boolean;
|
||||
__ldHold?: boolean;
|
||||
__ldAnswer?: (yes: boolean) => void;
|
||||
};
|
||||
w.__ldSigned = [];
|
||||
w.injectedWeb3 = {
|
||||
'duniter-connect': {
|
||||
@@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri
|
||||
accounts: { get: async () => list, subscribe: () => () => undefined },
|
||||
signer: {
|
||||
signPayload: async (payload: Record<string, unknown>) => {
|
||||
if (w.__ldHold) {
|
||||
const yes = await new Promise<boolean>((resolve) => (w.__ldAnswer = resolve));
|
||||
if (!yes) throw new Error('Cancelled');
|
||||
}
|
||||
if (w.__ldRefuse) throw new Error('Cancelled');
|
||||
w.__ldSigned.push(payload);
|
||||
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
|
||||
@@ -79,6 +91,7 @@ export async function chain(page: Page, docPath = DOC) {
|
||||
return {
|
||||
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
|
||||
node,
|
||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
|
||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') =>
|
||||
(await page.request.post(`${base}/control/${what}`)).json(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
||||
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
||||
await shot(page, '6-vote-ferme');
|
||||
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
||||
await page.goto(`${DOC}?onglet=panier`);
|
||||
const basket = page.locator('aside.panel');
|
||||
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
|
||||
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
|
||||
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
|
||||
// The test group still votes, directly.
|
||||
await page.goto(TEST_DOC);
|
||||
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user