// 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);