417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
|
|
// 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) =>
|
|||
|
|
(/<div id="login_error">([\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 <file> on line <n>", and
|
|||
|
|
// with html_errors on it wraps the label in <b>. 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(`<b>(?:${LABEL})</b>:[^<]{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);
|