Files
marcandClaude Opus 5 ead51b9fa0 Hold down the place the picture is looked for
The suite proved a picture was drawn somewhere on the page. Where it belongs
is the account button at the top of the sidebar — the person icon's place —
so that is what the check now names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:24:45 +02:00

327 lines
14 KiB
JavaScript

/* Signing in through an identity provider.
*
* The provider is real enough to be worth testing against: it signs a real
* RS256 token with a key InkNote fetches and checks (e2e/fakeIdp.mjs). So the
* claims under test are not "a button exists" but:
*
* • the whole redirect journey ends in a signed-in app, with an account that
* did not exist before — and never an administrator;
* • the same person coming back a second time is the same account, and an
* account that already existed under that name is linked rather than
* duplicated;
* • the token in the URL is a one-time ticket, not the session token, and the
* address is cleaned behind it;
* • a refusal at the provider, a token signed with the wrong key and a token
* addressed to somebody else all end at the sign-in screen, signed out.
*
* The last group is the point of the exercise. A sign-in that only works is not
* evidence of anything.
*/
import { BASE, api, check, launch, newPage, report } from "./lib.mjs";
import { startFakeIdp } from "./fakeIdp.mjs";
const ISSUER = "http://idp.test:3011";
const ADMIN = {
user: process.env.INKNOTE_ADMIN || "marc",
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
};
const unique = Math.floor(Math.random() * 1e6);
const idp = await startFakeIdp({
port: 3011,
issuer: ISSUER,
clientId: "inknote-test",
clientSecret: "test-secret",
});
const browser = await launch();
let adminPage;
const madeUsers = [];
try {
/* An administrator, to look at the accounts this creates and to clean up. */
adminPage = await newPage(browser);
await adminPage.goto(BASE, { waitUntil: "networkidle" });
await adminPage.getByPlaceholder("Username").fill(ADMIN.user);
await adminPage.getByPlaceholder("Password").fill(ADMIN.pass);
await adminPage.getByRole("button", { name: "Sign In" }).click();
await adminPage.waitForSelector("text=Dashboard", { timeout: 20000 });
const accounts = async () => (await api(adminPage, "GET", "/admin/users")).body;
const accountNamed = async (name) => (await accounts()).find((u) => u.username === name);
/* ── the offer ── */
const page = await newPage(browser);
const seen = [];
page.on("framenavigated", (f) => f === page.mainFrame() && seen.push(f.url()));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const methods = await page.evaluate(async () => (await fetch("/api/auth/methods")).json());
check(methods?.oidc?.enabled === true, "the server says there is a second way in", JSON.stringify(methods));
check(
!JSON.stringify(methods).includes("secret") && !JSON.stringify(methods).includes("client"),
"and says nothing else about it to a stranger",
JSON.stringify(methods)
);
check((await page.locator("[data-oidc-signin]").count()) === 1, "the sign-in screen offers it");
check(
(await page.locator("[data-oidc-signin]").innerText()).includes("Test IdP"),
"under the name it was given",
await page.locator("[data-oidc-signin]").innerText()
);
check((await page.getByPlaceholder("Password").count()) === 1, "and the password form is still there");
/* ── someone the app has never seen ── */
const newcomer = `sso-newcomer-${unique}`;
await idp.setNext({
identity: { sub: `subject-new-${unique}`, preferred_username: newcomer, name: "A Newcomer" },
});
await page.locator("[data-oidc-signin]").click();
await page.waitForLoadState("networkidle");
check(page.url().startsWith(`${ISSUER}/authorize`), "pressing it leads to the provider", page.url());
const authorizeUrl = new URL(page.url());
check(
authorizeUrl.searchParams.get("code_challenge_method") === "S256" &&
!!authorizeUrl.searchParams.get("state") &&
!!authorizeUrl.searchParams.get("nonce"),
"with PKCE, a state and a nonce",
authorizeUrl.search.slice(0, 120)
);
await page.locator("[data-idp-approve]").click();
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
await page.waitForTimeout(1500);
check(true, "approving there signs in here");
const created = await accountNamed(newcomer);
madeUsers.push(created?.id);
check(!!created, "an account was made for them", newcomer);
check(created?.isAdmin === false, "and it is not an administrator");
check(created?.signsInWithProvider === true, "and has no password of its own");
check(created?.linkedToProvider === true, "and is marked as belonging to the provider");
/* The picture belongs where the person icon was: in the account button at the
top of the sidebar. That is the place it is looked for. */
check(
(await page.locator('[title="Account"] [data-avatar]').count()) === 1,
"the account button in the sidebar wears the picture instead of the person icon"
);
/* ── what the two people involved see ──
* The one signing in: no password tab, because there is no password behind
* it. The administrator: which accounts these are, without asking anybody. */
await page.getByTitle("Account").click();
await page.waitForTimeout(900);
const tabs = await page.locator("button").allInnerTexts();
check(
!(await page.getByRole("button", { name: "Password", exact: true }).count()),
"the account panel offers no password to change",
tabs.join(" | ").slice(0, 120)
);
check(
(await page.locator("[data-account-sso]").count()) === 1,
"and says which provider it signs in through instead"
);
check(
(await page.locator("[data-account-sso]").innerText()).includes("Test IdP"),
"by name",
await page.locator("[data-account-sso]").innerText()
);
/* The picture the provider has of them. It is fetched from *this* server with
a bearer token — an `<img src>` cannot carry one, so the bytes come through
the API and become an object URL. Which is also why "it is on the page" is
not enough: ask the browser whether it actually decoded. */
const drawn = await page.locator("[data-avatar]").first().evaluate((img) => ({
src: img.currentSrc || img.src,
width: img.naturalWidth,
}));
/* The provider keeps this one out of the token and behind its own door, which
is what a lean provider does — so this passing means both the second
question to /userinfo and the token sent with the picture request. */
check(drawn.width > 0, "the profile picture from the provider is drawn", JSON.stringify(drawn));
check(
drawn.src.startsWith("blob:"),
"from bytes this server handed over, not from the provider's address",
drawn.src.slice(0, 40)
);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await adminPage.getByTitle("Account").click();
await adminPage.waitForTimeout(700);
await adminPage.getByRole("button", { name: "Administration" }).click();
await adminPage.waitForTimeout(1500);
const ssoRow = adminPage.locator(`text=${newcomer}`).locator("xpath=ancestor::div[1]");
check(
(await adminPage.locator("[data-user-sso]").count()) >= 1,
"the administrator sees which accounts come from the provider",
`${await adminPage.locator("[data-user-sso]").count()} marked`
);
check(
(await ssoRow.innerText()).includes("SSO"),
"on the row of the account itself",
(await ssoRow.innerText()).replace(/\n/g, " ").slice(0, 100)
);
check(
(await adminPage.locator("body").innerText()).includes("Give a password"),
"and is offered to give it a password rather than to reset one"
);
check(
(await adminPage.locator(`[data-avatar="${created.id}"]`).count()) === 1,
"and sees their picture beside the name"
);
await adminPage.keyboard.press("Escape");
await adminPage.waitForTimeout(400);
await adminPage.keyboard.press("Escape");
await adminPage.waitForTimeout(600);
/* The address is cleaned, and what was in it was a ticket, not the token. */
const returned = seen.find((u) => u.includes("oidc="));
check(!!returned, "the provider sent the browser home with something in the URL");
check(
!!returned && !returned.includes(await page.evaluate(() => localStorage.getItem("inknote_token"))),
"which was not the session token"
);
check(!page.url().includes("oidc"), "and the address was cleaned afterwards", page.url());
/* A session lives in two places — IndexedDB and localStorage — so "signed out
again" means a browser that never saw this one. Every case below gets one. */
const signedOutBrowser = () => newPage(browser);
/* ── the ticket is single use ── */
const replay = await signedOutBrowser();
await replay.goto(returned, { waitUntil: "networkidle" });
await replay.waitForTimeout(2000);
check(
(await replay.getByPlaceholder("Password").count()) === 1,
"the same ticket a second time is worth nothing",
replay.url()
);
await replay.context().close();
/* ── the same person again is the same account ── */
const again = await signedOutBrowser();
await again.goto(BASE, { waitUntil: "networkidle" });
await again.locator("[data-oidc-signin]").click();
await again.waitForLoadState("networkidle");
await again.locator("[data-idp-approve]").click();
await again.waitForSelector("text=Dashboard", { timeout: 20000 });
await again.waitForTimeout(1200);
const afterSecond = (await accounts()).filter((u) => u.username.startsWith(`sso-newcomer-${unique}`));
check(afterSecond.length === 1, "signing in again does not make a second account", `${afterSecond.length}`);
await again.context().close();
/* ── an account that already existed here ── */
const existing = `sso-existing-${unique}`;
const madeByAdmin = await api(adminPage, "POST", "/admin/users", {
username: existing,
password: "e2epass123",
displayName: "Already Here",
});
madeUsers.push(madeByAdmin.body?.id);
check(madeByAdmin.status === 201, "an ordinary account exists under that name");
await idp.setNext({
identity: { sub: `subject-existing-${unique}`, preferred_username: existing, name: "Already Here" },
});
const second = await signedOutBrowser();
await second.goto(BASE, { waitUntil: "networkidle" });
await second.locator("[data-oidc-signin]").click();
await second.waitForLoadState("networkidle");
await second.locator("[data-idp-approve]").click();
await second.waitForSelector("text=Dashboard", { timeout: 20000 });
await second.waitForTimeout(1200);
/* An account that had a password and was then linked: it keeps the password,
and still manages it nowhere but through an administrator. */
await second.getByTitle("Account").click();
await second.waitForTimeout(900);
check(
!(await second.getByRole("button", { name: "Password", exact: true }).count()),
"an account linked to the provider is offered no password tab either"
);
check(
(await second.locator("[data-account-sso]").innerText()).includes("administrator"),
"and is told who changes the one it has",
await second.locator("[data-account-sso]").innerText()
);
await second.keyboard.press("Escape");
await second.waitForTimeout(400);
const linked = (await accounts()).filter((u) => u.username === existing);
check(linked.length === 1, "the identity is linked to it rather than duplicated", `${linked.length}`);
check(
linked[0]?.signsInWithProvider === false,
"and it keeps the password it already had — one way in does not close the other"
);
check(
linked[0]?.linkedToProvider === true,
"while still being shown as one that can use the provider"
);
await second.context().close();
/* ── the ways it must fail ── */
const signedOut = async (page) => (await page.getByPlaceholder("Password").count()) === 1;
const denied = await signedOutBrowser();
await denied.goto(BASE, { waitUntil: "networkidle" });
await denied.locator("[data-oidc-signin]").click();
await denied.waitForLoadState("networkidle");
await denied.locator("[data-idp-deny]").click();
await denied.waitForTimeout(2000);
check(await signedOut(denied), "a refusal at the provider ends signed out");
check(
(await denied.locator("body").innerText()).includes("did not allow"),
"and says so on the sign-in screen",
(await denied.locator("body").innerText()).slice(0, 120)
);
await denied.context().close();
const refusedBy = async (label, misbehave) => {
await idp.setNext({
identity: { sub: `subject-forged-${unique}`, preferred_username: `sso-forged-${unique}`, name: "Forged" },
misbehave,
});
const forged = await signedOutBrowser();
await forged.goto(BASE, { waitUntil: "networkidle" });
await forged.locator("[data-oidc-signin]").click();
await forged.waitForLoadState("networkidle");
await forged.locator("[data-idp-approve]").click();
await forged.waitForTimeout(2000);
check(await signedOut(forged), label);
check(
!(await accounts()).some((u) => u.username === `sso-forged-${unique}`),
`${label} — and no account was made from it`
);
await forged.context().close();
};
await refusedBy("a token signed with a key the provider does not publish is refused", { wrongKey: true });
await refusedBy("a token addressed to another client is refused", { wrongAudience: true });
/* ── an account made this way has no password ── */
const tryPassword = await adminPage.evaluate(async (name) => {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: "" }),
});
return r.status;
}, newcomer);
check(tryPassword === 401, "and cannot be signed into with a password", `${tryPassword}`);
} catch (e) {
check(false, "suite ran to the end", e.stack || e.message);
} finally {
/* Tidy up whatever the run made, including the accounts the provider caused. */
try {
for (const id of madeUsers.filter(Boolean)) await api(adminPage, "DELETE", `/admin/users/${id}`);
await adminPage?.context().close();
} catch {
/* the report matters more than the cleanup */
}
await browser.close();
await idp.stop();
process.exit(report());
}