// End-to-end checks against a running Sirius Press instance. // // The other suites prove the cryptography in isolation. This one proves the // thing that actually matters: that a signature made in a browser gets a real // WordPress session out of a real WordPress, and that the failure paths fail. // // node tests/live.mjs // // Expects: // BASE the site's URL (default http://127.0.0.1:8760) // WALLET a JSON file holding {"phrase": "...", "address": "bchtest:..."} // for an existing administrator on that site // // docs/testing.md has the recipe for standing up a throwaway instance with // SQLite and PHP's built-in server — no database server, no Docker. // // Nothing here is destructive except that it creates one account per run, in // a site you were already willing to point a test at. import { readFileSync } from "node:fs"; globalThis.window = globalThis; const ASSETS = new URL("../plugins/sirius-press-auth/assets/", import.meta.url); new Function(readFileSync(new URL("bip39-en.js", ASSETS), "utf8"))(); new Function(readFileSync(new URL("wallet.js", ASSETS), "utf8"))(); const BASE = (process.env.BASE || "http://127.0.0.1:8760").replace(/\/$/, ""); const WALLET = process.env.WALLET; if (!WALLET) { console.error("set WALLET to a JSON file with the administrator's phrase and address"); process.exit(2); } const admin = JSON.parse(readFileSync(WALLET, "utf8")); const W = window.SiriusWallet; /** * Where the REST API lives. * * A site without pretty permalinks serves it at ?rest_route= rather than * /wp-json/, and a fresh install has exactly that. Probing both is the * difference between testing the API and testing the permalink setting. */ const restBase = await (async () => { const pretty = await fetch(`${BASE}/wp-json/`).catch(() => null); if (pretty && pretty.ok && (pretty.headers.get("content-type") || "").includes("json")) { return (path) => `${BASE}/wp-json${path}`; } return (path) => `${BASE}/index.php?rest_route=${encodeURIComponent(path)}`; })(); const restUrl = (path, query = "") => { const url = restBase(path); return query ? url + (url.includes("?") ? "&" : "?") + query : url; }; let pass = 0; let fail = 0; function check(condition, what, extra = "") { if (condition) { pass++; console.log(` ok ${what}`); } else { fail++; console.log(` FAIL ${what}`); if (extra) console.log(` ${extra}`); } } function group(name) { console.log(`\n ${name}`); } const decode = (s) => s .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/�?39;/g, "'") .replace(/—/g, "—") .replace(/’/g, "’"); /** A cookie jar and a fetch that uses it — one browser, in effect. */ function Session() { const jar = new Map([["wordpress_test_cookie", "WP%20Cookie%20check"]]); return { loggedIn: () => [...jar.keys()].some((k) => k.startsWith("wordpress_logged_in_")), async go(path, options = {}) { const res = await fetch(BASE + path, { redirect: "manual", ...options, headers: { cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "), ...(options.headers || {}), }, }); for (const raw of res.headers.getSetCookie?.() ?? []) { const pair = raw.split(";")[0]; const eq = pair.indexOf("="); jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1)); } return { res, body: await res.text() }; }, }; } const post = (fields) => ({ method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields), }); /** Fetch a page and pull out the challenge it is offering. */ async function challenge(session, path) { const { body } = await session.go(path); const message = /class="sirius-wallet__message"[^>]*>([\s\S]*?)<\/textarea>/.exec(body); return { body, nonce: /name="sirius_nonce" value="([^"]+)"/.exec(body)?.[1], message: message ? decode(message[1]) : null, }; } const errorIn = (html) => (/
([\s\S]*?)<\/div>/.exec(html)?.[1] || "") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); // ---------------------------------------------------------------- sign in group("wallet sign-in"); { const s = Session(); const c = await challenge(s, "/wp-login.php"); check(Boolean(c.nonce && c.message), "the login page issues a challenge"); const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" }); check(wallet.address === admin.address, "the phrase derives the administrator's address"); const { res } = await s.go( "/wp-login.php", post({ log: "", pwd: "", sirius_nonce: c.nonce, sirius_purpose: "login", sirius_signature: await wallet.sign(c.message), sirius_address: wallet.address, "wp-submit": "Log In", redirect_to: `${BASE}/wp-admin/`, testcookie: "1", }), ); check(res.status === 302, `a valid signature signs you in (${res.status})`); check(s.loggedIn(), "an ordinary WordPress session cookie is issued"); const dash = await s.go("/wp-admin/"); check(dash.res.status === 200 && /Dashboard/i.test(dash.body), "wp-admin loads with that session"); // Every screen the fork adds has to render without a PHP diagnostic. const screens = [ ["Settings", "/wp-admin/admin.php?page=sirius-press"], ["Sign-in", "/wp-admin/admin.php?page=sirius-press-auth"], ["Publishing", "/wp-admin/admin.php?page=sirius-press-export"], ["Inbox", "/wp-admin/admin.php?page=sirius-inbox"], ["Profile", "/wp-admin/profile.php"], ["Users", "/wp-admin/users.php"], ["Plugins", "/wp-admin/plugins.php"], ]; for (const [label, path] of screens) { const page = await s.go(path); // Match PHP's own diagnostic output, not the word "Warning" wherever it // happens to appear. PHP always appends " in on line ", and // with html_errors on it wraps the label in . Without that anchor this // check trips over plugins whose translation strings contain the word — // Yoast ships several. const LABEL = "Fatal error|Parse error|Warning|Notice|Deprecated"; const diagnostic = new RegExp(`(?:${LABEL}):[^<]{0,200}`).exec(page.body) || new RegExp(`(?:${LABEL}):[^\n<]{0,200}? in [^\n<]{0,200}? on line \d+`).exec(page.body); check( page.res.status === 200 && !diagnostic, `${label} renders cleanly (${page.res.status})`, diagnostic ? diagnostic[0] : "", ); } const settings = await s.go("/wp-admin/admin.php?page=sirius-press"); check(/Publishing address/.test(settings.body), "settings shows the publishing status block"); const profile = await s.go("/wp-admin/profile.php"); check(profile.body.includes(admin.address), "the profile shows the attached wallet"); const users = await s.go("/wp-admin/users.php"); check(/Wallet/.test(users.body), "the users list has a Wallet column"); check(!/noreply\+/.test(users.body), "and does not show placeholder email addresses"); } // ----------------------------------------------------- one signature, one use group("a signature is worth one use"); { const c = await challenge(Session(), "/wp-login.php"); const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" }); const signature = await wallet.sign(c.message); const fields = { log: "", pwd: "", sirius_nonce: c.nonce, sirius_purpose: "login", sirius_signature: signature, sirius_address: wallet.address, "wp-submit": "Log In", testcookie: "1", }; const first = await Session().go("/wp-login.php", post(fields)); check(first.res.status === 302, "the first use is accepted"); const second = await Session().go("/wp-login.php", post(fields)); check(second.res.status === 200, "replaying it does not sign anyone in"); check(/already been used/i.test(second.body), "and the page says why", errorIn(second.body)); } // ------------------------------------------------------------ wrong wallet group("a stranger's signature"); { const s = Session(); const c = await challenge(s, "/wp-login.php"); const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" }); const { res, body } = await s.go( "/wp-login.php", post({ log: "", pwd: "", sirius_nonce: c.nonce, sirius_purpose: "login", sirius_signature: await stranger.sign(c.message), sirius_address: stranger.address, "wp-submit": "Log In", testcookie: "1", }), ); check(res.status === 200, "an unknown wallet is not signed in"); check(/No account/i.test(body), "and is told no account uses it", errorIn(body)); } // ------------------------------------------------------- tampered message group("a signature over different text"); { const s = Session(); const c = await challenge(s, "/wp-login.php"); const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" }); const { res, body } = await s.go( "/wp-login.php", post({ log: "", pwd: "", sirius_nonce: c.nonce, sirius_purpose: "login", sirius_signature: await wallet.sign(c.message + " "), sirius_address: wallet.address, "wp-submit": "Log In", testcookie: "1", }), ); check(res.status === 200, "a signature over altered text is refused"); check(/does not match/i.test(body), "and says the text does not match", errorIn(body)); } // ------------------------------------------- purposes are not interchangeable group("a login signature cannot create an account"); { const s = Session(); const login = await challenge(s, "/wp-login.php"); const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" }); const { body } = await s.go( "/wp-login.php?action=sirius_register", post({ user_login: "", sirius_nonce: login.nonce, sirius_purpose: "register", sirius_signature: await stranger.sign(login.message), sirius_address: stranger.address, }), ); check( /does not match/i.test(body), "signing the login text does not register an account", errorIn(body), ); } // ------------------------------------------------------------- registration group("registration"); { const s = Session(); const c = await challenge(s, "/wp-login.php?action=sirius_register"); check(Boolean(c.nonce && c.message), "the registration page issues its own challenge"); check(/create an account/i.test(c.message || ""), "the challenge says what it is for", (c.message || "").split("\n")[0]); check(!/name="user_email"|type="email"/.test(c.body), "there is no email field on it"); const wallet = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" }); const suffix = Math.random().toString(36).slice(2, 8); const { res } = await s.go( "/wp-login.php?action=sirius_register", post({ user_login: `reader_${suffix}`, sirius_nonce: c.nonce, sirius_purpose: "register", sirius_signature: await wallet.sign(c.message), sirius_address: wallet.address, }), ); check(res.status === 302, `one signature creates the account and signs it in (${res.status})`); check(s.loggedIn(), "the new account has a session straight away"); /* * Prove the account was bound to the right key by signing in again with it, * rather than by loading wp-admin. A subscriber cannot necessarily reach * wp-admin at all — WooCommerce redirects them away by default — and that * would make this assertion a test of whichever plugins happen to be * installed instead of a test of registration. */ const fresh = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login")); const freshJson = await fresh.json().catch(() => ({})); const back = await fetch(restUrl("/sirius-press/v1/login"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nonce: freshJson.nonce, signature: await wallet.sign(freshJson.message) }), }); const backJson = await back.json().catch(() => ({})); check( back.status === 200 && backJson.address === wallet.address, "the new account signs in again with the same wallet", `status ${back.status}, address ${backJson.address}`, ); const s2 = Session(); const c2 = await challenge(s2, "/wp-login.php?action=sirius_register"); const again = await s2.go( "/wp-login.php?action=sirius_register", post({ user_login: "", sirius_nonce: c2.nonce, sirius_purpose: "register", sirius_signature: await wallet.sign(c2.message), sirius_address: wallet.address, }), ); check( again.res.status === 200 && /already has an account/i.test(again.body), "the same wallet cannot register twice", errorIn(again.body), ); } // ------------------------------------------------------------ recovery page group("the recovery page"); { const { res, body } = await Session().go("/wp-login.php?action=lostpassword"); check(res.status === 200, "it loads"); check(/cannot reset your account/i.test(body), "it says the site cannot reset anything"); check(!/Get New Password/i.test(body), "and offers no reset form"); } // -------------------------------------------------------------- REST surface group("the REST endpoints"); { const res = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login")); const json = await res.json().catch(() => ({})); check(res.status === 200, `challenge returns 200 (${res.status})`); check(Boolean(json.nonce && json.message), "and carries a nonce and a message"); const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" }); const login = await fetch(restUrl("/sirius-press/v1/login"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nonce: json.nonce, signature: await wallet.sign(json.message) }), }); const out = await login.json().catch(() => ({})); check(login.status === 200 && out.ok === true, `login returns a session (${login.status})`); check(out.address === admin.address, "and reports the address that signed"); const bad = await fetch(restUrl("/sirius-press/v1/login"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nonce: json.nonce, signature: "not-a-signature" }), }); check(bad.status >= 400, `a bad signature is rejected with an error status (${bad.status})`); // Registration through the API must insist on the claimed address for the // same reason the form does. const reg = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=register")); const regJson = await reg.json().catch(() => ({})); if (regJson.nonce) { const orphan = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" }); const noAddr = await fetch(restUrl("/sirius-press/v1/register"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nonce: regJson.nonce, signature: await orphan.sign(regJson.message) }), }); check(noAddr.status === 400, `register without an address is refused (${noAddr.status})`); } } console.log(`\n ${pass + fail} checks, ${fail ? `${fail} FAILED` : "all passed"}\n`); process.exit(fail ? 1 : 0);