Files
decision/web/e2e/chain-mock.mjs
T
YvvandClaude Opus 5.5 b260596cfd v3 : le vote déplié sous l'article, le panier, la signature par le portefeuille Ğ1
- Le vote se déplie sous l'article, façon accordéon, l'URL pour état (?vote=) :
  niveau d'eau, totaux, seuil et voix manquantes, ton vote et où il en est
  (enregistré, inscrit sur la chaîne, en attente, à reconfirmer, non compté),
  inertie et formule. Le reste d'une version (coquilles, traductions,
  signalements, historique) passe dans l'onglet « Détails » du panneau.
- Le panier : article par article, « tout pour / tout contre » par section ou
  pour tout le document, jamais pour un article à plusieurs versions ; un seul
  geste (une écriture, ou une transaction).
- La signature : le portefeuille de la page (injectedWeb3), polkadot-api chargé
  au moment de signer ; sans portefeuille, Ğecko par lien et QR ; la page
  attend la confirmation. Un bulletin que le portefeuille n'a pas signé est
  lâché aussitôt (DELETE /api/ballots/{id}).
- Pastilles d'ambiance : flèches dans le groupe, anneau de focus pointillé à
  la couleur d'accent de la page (3:1 au moins), distinct du choix.
- e2e : chaîne simulée (indexeur et nœud), faux portefeuille ; C21–C24, et
  C4, C5, C6, C11, C17, C19, C20, M1 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:47:26 +01:00

121 lines
5.4 KiB
JavaScript

// A simulated Ğ1 chain for the end-to-end tests (ADR 0008): never a real node,
// never a real transaction. Two doors:
//
// - `/rpc`, the « node »: the page, in test mode, sends what the fake wallet
// signed (JSON as hex, see `testExtrinsic` in src/lib/wallet/injected.ts);
// 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.
//
// 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`.
//
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { blake2b } from '@noble/hashes/blake2.js';
// Substrate's well-known development keys as Ğ1 addresses, with the identity each
// one has on this simulated chain. Bob has an identity, but is not a member.
const IDENTITIES = {
g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu: { index: 9001, name: 'Alice', isMember: true },
g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g: { index: 9002, name: 'Bob', isMember: false },
g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi: { index: 9003, name: 'Charlie', isMember: true },
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
};
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
function send(res, status, body, cors = false) {
const headers = { 'content-type': 'application/json' };
if (cors) Object.assign(headers, { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' });
res.writeHead(status, headers);
res.end(body === undefined ? '' : JSON.stringify(body));
}
async function read(req) {
let raw = '';
for await (const chunk of req) raw += chunk;
try {
return JSON.parse(raw || '{}');
} catch {
return {};
}
}
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
function include(fields) {
state.block += 1;
state.submitted += 1;
const comment = {
id: `${String(state.block).padStart(10, '0')}-${hex(Buffer.from(String(state.block))).slice(-5)}-000002`,
blockNumber: state.block,
remark: fields.remark,
author: { id: fields.address, identity: IDENTITIES[fields.address] ?? null },
transfersByCommentId: { nodes: [{ fromId: fields.address, toId: fields.dest, amount: String(fields.amount) }] },
hidden: state.hiding,
};
state.comments.push(comment);
return comment;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://mock');
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
if (url.pathname === '/rpc' && req.method === 'POST') {
const body = await read(req);
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 {
fields = JSON.parse(Buffer.from(String(body.params?.[0] ?? '').slice(2), 'hex').toString('utf8'));
} catch {
fields = null;
}
if (!fields?.signature || !fields.remark || !fields.address) {
return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: 1010, message: 'Invalid Transaction' } }, true);
}
const c = include(fields);
return send(res, 200, { jsonrpc: '2.0', id: body.id, result: `0x${hex(blake2b(Buffer.from(c.id), { dkLen: 32 }))}` }, true);
}
if (url.pathname === '/v1/graphql' && req.method === 'POST') {
const body = await read(req);
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)
.map(({ hidden: _, ...c }) => c);
return send(res, 200, { data: { txComments: { nodes } } });
}
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
const what = url.pathname.slice('/control/'.length);
if (what === 'hide') state.hiding = true;
else if (what === 'show') state.hiding = false;
else if (what === 'release') {
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 !== '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);
}
send(res, 404, { error: 'not_found' }, true);
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const file = process.argv[2];
if (file) writeFileSync(file, String(port));
console.log(`chain mock on 127.0.0.1:${port}`);
});