Files
ng-eventually/packages/ng-e2e-helpers/src/wallet.ts
Sylvain Duchesne b50591f5bd feat(e2e-helpers): fabriquer un portefeuille, pour un test comme pour un déploiement
Trois appelants veulent la même chose — un portefeuille neuf, ses octets et son
mot de passe : la suite du polyfill à son démarrage, la suite d'une application
au sien, et un humain une fois pour provisionner un déploiement. Rien ne
produisait ce fichier ; il fallait aller le chercher à la main sur nextgraph.eu.

Mêmes entrées, mêmes sorties, mais des appelants de nature différente : une
suite ne va pas lancer un sous-processus et analyser sa sortie, et un humain ne
va pas écrire un fichier jetable pour appeler une fonction. Donc une fonction,
et un script mince par-dessus.

mintWalletBytes(password, name) rend les OCTETS, pas un chemin. C'est une
correction de ce qui existait : exportWalletFile imposait le disque à tout le
monde, et la suite applicative écrivait un fichier temporaire pour le relire
aussitôt en mémoire, puis devait le nettoyer. Elle ne le fait plus — serveApp
prend les octets. Qui veut un fichier l'écrit ; personne n'y est forcé.

Le script exige --password et refuse d'en inventer un, et refuse d'écraser un
.ngw existant sans --force, vérifié AVANT de fabriquer quoi que ce soit.
Exécuté pour de vrai : 800 octets, et le mot de passe imprimé est celui passé en
entrée. C'est le seul des trois cas qu'aucune suite n'exerce, donc le seul qui
pouvait être livré cassé sans que rien ne le dise.

Ce qui ne change pas : les suites fabriquent un portefeuille par exécution et
n'en héritent jamais ; le provisionnement veut l'inverse, durable et conservé.
Ils partagent la fabrication et l'export, ils divergent sur la durée de vie.
Les identifiants en dur deviennent un paramètre — les suites passent toujours
les leurs, le script prend ceux qu'on lui donne, et il n'existe aucun mot de
passe par défaut.
2026-08-16 15:44:37 +02:00

329 lines
15 KiB
TypeScript

/**
* The wallet lifecycle: mint one by driving the wallet application's real interface, get its
* bytes out (`.ngw` contents, file optional), and put a `.ngw` file into a browser profile.
*
* ── Why the real interface and not a shortcut ────────────────────────────────
* A wallet obtained any other way is not the one a person has. The wallet application is an
* application like any other, so this drives it: click for click, field for field. That is
* also what makes the harness notice when the flow upstream changes, instead of testing
* against a fixture that quietly stopped resembling it.
*
* The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`.
*
* ── On the fixed waits in these flows ────────────────────────────────────────
* The creation and import flows below contain a handful of `waitForTimeout` calls, each on a
* step where the wallet application offers NO observable signal that the work is finished
* (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing).
* They are inherited as-is, with their measured durations, and they are the only fixed waits
* in this package — everything else waits for a condition. They are the first thing to replace
* if the wallet application ever grows a marker to wait on.
*/
import type { BrowserContext, Page } from "playwright";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { closeQuietly, within } from "./deadline";
import { closeContext, launchWatchedContext, newPage } from "./browser";
import { newRunProfile, type RunProfile } from "./profiles";
import { serveOnEphemeralPort } from "./serve";
import { setupBrokerPage } from "./broker";
import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui";
import type { ExportedWallet } from "./wallet-export-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
/**
* The whole wallet export measures ~7 s against a real broker. Bounded at 60 s ≈ 8x.
*
* It was two minutes once, and that cost a run twice over: the export hung, and the suite
* spent two full minutes reaching a verdict it could have reached in one — before dying
* without a summary, because an export runs in the SETUP, ahead of every journey.
*/
const EXPORT_MS = 60_000;
/** The export page appearing, then its session connecting. Both against a live broker. */
const EXPORT_PAGE_MS = 30_000;
const EXPORT_CONNECT_MS = 60_000;
/** A wallet's name and the password that opens it. */
export interface WalletCredentials {
readonly name: string;
readonly password: string;
}
/**
* The name a wallet gets when the caller has no opinion about it.
*
* A wallet's name is what a person types to pick one among several, and nothing in NextGraph
* keys off it — so a caller minting the only wallet it will ever hold has nothing to decide
* here. The PASSWORD never gets a default: it is the only thing between the file and whoever
* finds it, and a defaulted one would be a published secret.
*/
export const DEFAULT_WALLET_NAME = "ng-wallet";
/**
* Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once.
*
* The first unlock is not decoration: it is what bootstraps the verifier's repos from the
* broker, and a wallet that has never been unlocked is not usable by an application.
*/
export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise<void> {
const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx));
page.on("pageerror", () => {});
await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator(WALLET_CREATION.username);
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(credentials.name);
const passwordInput = page.locator(WALLET_CREATION.password);
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(credentials.password);
const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker. This is what a brand-new
// wallet does on its very first unlock.
const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet);
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator(WALLET_CREATION.passwordField);
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(credentials.password);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
}
/**
* This run's physical user: a profile of its own, and a wallet minted into it. The creation
* context is closed — the caller opens its own contexts over `profile.dir`, one at a time.
*
* One per run, never inherited from a previous one: see `profiles.ts` for why that is a
* property of the directory rather than a rule anyone has to remember.
*/
export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise<RunProfile> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("wallet-creation", profile.dir);
try {
await createWalletInContext(ctx, credentials);
} finally {
const { closeContext } = await import("./browser");
await closeContext("wallet-creation", ctx);
}
return profile;
}
/**
* The same, with the context left OPEN.
*
* For the cold-start case: the caller then opens its application in the SAME profile, i.e. the
* very first application session over a wallet that has never run one. Closing and relaunching
* would not be the same thing.
*/
export async function mintWalletProfileKeepingContext(
purpose: string,
credentials: WalletCredentials,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("fresh-wallet", profile.dir);
await createWalletInContext(ctx, credentials);
const first = ctx.pages()[0];
if (first !== undefined) await first.close().catch(() => {});
return { ctx, profile };
}
/**
* A context on an EMPTY profile: no wallet, no local repo cache.
*
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: a wallet's
* repos are on the broker but NOT in this profile, so a session over it starts with nothing
* local. The caller imports a wallet (see {@link importWalletFile}) before opening the
* application.
*/
export async function emptyProfileContext(
purpose: string,
): Promise<{ ctx: BrowserContext; profile: RunProfile }> {
const profile = newRunProfile(purpose);
const ctx = await launchWatchedContext("clean-profile", profile.dir);
return { ctx, profile };
}
/**
* Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it.
*
* After this the profile holds the wallet — but NOT the repos' local cache — so the next
* application session over it hits the broker-only cold-start.
*
* The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password
* can then be tested by reading it off its own screen and passing it here, which is the only
* way to tell that what the barrier shows is what actually opens the file. A default would
* make that step untestable: the import would succeed on a barrier showing anything at all,
* including nothing.
*/
export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void> {
await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" });
// Let the application render and attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath);
const passwordInput = page.locator(WALLET_IMPORT.passwordField).first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(password);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/** The export page, bundled once per run. */
let exportBundle: string | null = null;
function buildExportBundle(): string {
if (exportBundle !== null) return exportBundle;
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js");
const entry = path.join(__dirname, "wallet-export-page.ts");
execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, {
stdio: "pipe",
cwd: __dirname,
timeout: BUILD_MS,
});
exportBundle = fs.readFileSync(out, "utf-8");
return exportBundle;
}
/**
* The bytes of the wallet held by `ctx`'s profile — the contents of a `.ngw` file, without a
* `.ngw` file.
*
* Why an application's own suite needs this: a deployment that hands a wallet out — an access
* barrier with a download link, say — must be tested against a REAL wallet. Serving a
* placeholder there makes the download step a decoration: importing it cannot let anybody in,
* so the check that the link works cannot fail for the right reason.
*
* ── Why BYTES are the primitive and the file the convenience ─────────────────
* Every caller here already has the bytes in hand — they arrive from the broker iframe — and
* only some of them want a file. This used to write one unconditionally, so a suite that
* serves a wallet from memory had to name a temp path, write it, read it straight back and
* remember to remove it: three steps and a cleanup to get back what the function had. A caller
* that genuinely wants a file writes these bytes (or calls {@link exportWalletFile}), which is
* one step in the direction nobody has to undo.
*/
export async function exportWalletBytes(
ctx: BrowserContext,
walletPassword: string,
): Promise<Uint8Array> {
const bundle = buildExportBundle();
const html =
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>wallet export</title></head>' +
'<body><script type="module" src="/wallet-export-page.js"></script></body></html>';
const { url, close } = await serveOnEphemeralPort((req, res) => {
if (req.url === "/wallet-export-page.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
const page = await newPage("the wallet export", ctx);
page.on("pageerror", () => {});
try {
const frame = await setupBrokerPage(page, url, walletPassword);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined,
{ timeout: EXPORT_PAGE_MS },
);
await frame.waitForFunction(
() => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected",
{ timeout: EXPORT_CONNECT_MS },
);
// `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of
// the two ways this harness used to hang for ever.
const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () =>
frame.evaluate(
() =>
(
window as unknown as { __ngWalletExport: { file(): Promise<ExportedWallet> } }
).__ngWalletExport.file(),
),
);
return new Uint8Array(Buffer.from(exported.b64, "base64"));
} finally {
await closeQuietly("the wallet export page", () => page.close());
close();
}
}
/**
* The same wallet, written to `ngwPath`, returning its size in bytes.
*
* For the callers that want a FILE — a browser's file input takes a path, and a person
* provisioning a deployment has to put the wallet somewhere. Everyone else takes the bytes.
*/
export async function exportWalletFile(
ctx: BrowserContext,
ngwPath: string,
walletPassword: string,
): Promise<number> {
const bytes = await exportWalletBytes(ctx, walletPassword);
fs.writeFileSync(ngwPath, bytes);
return bytes.length;
}
/**
* A wallet that did not exist a moment ago, as the bytes of a `.ngw`: mint one by walking the
* wallet application, take its bytes out, and drop everything else.
*
* The one call behind all three callers who need a fresh wallet — this package's own suite, a
* consuming application's suite, and a person provisioning a deployment (see `bin/mint-wallet.ts`,
* which is this function plus a `writeFileSync` and two `console.log`s).
*
* ── What it deliberately does NOT keep ───────────────────────────────────────
* The browser profile the wallet was minted in is discarded here. That is not a shortcut: the
* wallet's repos live on the broker and the bytes returned are what opens them, so the profile
* is scaffolding in every one of the three cases — including the durable one, where what is
* kept is the FILE the caller writes, never a browser directory.
*
* A suite that needs the profile to survive — because its actors sign in THROUGH it rather than
* importing the file — composes the two halves itself: {@link mintWalletProfile} for a profile
* that lasts the run, then {@link exportWalletBytes} over it. Same minting, same export,
* different lifetime, which is the only thing the two paths disagree about.
*/
export async function mintWalletBytes(
password: string,
name: string = DEFAULT_WALLET_NAME,
): Promise<Uint8Array> {
const profile = await mintWalletProfile(`minting the wallet "${name}"`, { name, password });
try {
// A context of its own over the profile, opened after the creation one closed — the order
// `mintWalletProfile` already imposes, and the one every export in this repository uses.
const ctx = await launchWatchedContext("wallet-export", profile.dir);
try {
return await exportWalletBytes(ctx, password);
} finally {
await closeContext("wallet-export", ctx);
}
} finally {
profile.discard();
}
}