browser-tests/tests/login_loop.rs
//! ★ **A sign-in lands, and stays** (HOL-LOGIN-LOOP, the owner on holger.rs 2026-10-30: "login →
//! login again", the page "just stands flashing, bouncing back and forth").
//!
//! After a passkey sign-in (the front page's one press, and the mailed sign-in link's
//! registration) the tab lands on `/overview` and stays there: no further navigation within
//! five seconds, and the console shows the person, never "Log in". A tab whose cookie holds a
//! session holger no longer knows (ended, or a pasted bearer that is no session) does not
//! bounce either: `/overview`, `/` and `/login` each load once and stay.
use std::time::Duration;
use holger_ui_browser_tests::browser::{Browser, Tab, SIGNED_IN_OR_REGISTERED, SIGN_OUT_TAB};
use holger_ui_browser_tests::rig::{Rig, CONSOLE};
/// Put before every document's own script: each document this tab loads is counted in the
/// tab's `sessionStorage` (it survives the navigations it counts).
const COUNT_LOADS: &str = "try { const n = Number(sessionStorage.getItem('__loads') || '0') + 1; sessionStorage.setItem('__loads', String(n)); const l = JSON.parse(sessionStorage.getItem('__urls') || '[]'); l.push(location.pathname + location.search); sessionStorage.setItem('__urls', JSON.stringify(l.slice(-40))); } catch (e) {}";
/// The front page's "Log in", enabled.
const LOG_IN_READY: &str = "(() => { const b = document.getElementById('enter'); return !!b && !b.disabled && b.offsetParent !== null && b.textContent.trim() === 'Log in'; })()";
/// The console's topbar names the person (holger's session answer rendered).
const TOPBAR_PERSON: &str = "(() => { const a = document.querySelector('.authbox .ident .ok'); return a ? a.textContent : ''; })()";
/// The console's topbar offers "Log in".
const TOPBAR_LOG_IN: &str = "(() => { const a = [...document.querySelectorAll('.authbox a')].find(x => x.textContent.trim() === 'Log in'); return !!a; })()";
async fn loads(tab: &Tab) -> (u64, String) {
let n = tab.js("Number(sessionStorage.getItem('__loads') || '0')").await.ok().and_then(|v| v.as_u64()).unwrap_or(0);
let urls = tab.js("sessionStorage.getItem('__urls') || '[]'").await.ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default();
(n, urls)
}
/// **The tab stays where it is**: no document is loaded in the next five seconds.
async fn stays(tab: &Tab, what: &str) {
// Let a navigation in flight land first, then count.
tokio::time::sleep(Duration::from_millis(500)).await;
let (before, _) = loads(tab).await;
let at = tab.url().await;
tokio::time::sleep(Duration::from_secs(5)).await;
let (after, urls) = loads(tab).await;
assert!(after <= before + 1 && after.saturating_sub(before) == 0, "{what}: the tab navigated {} more times in 5 s after landing on {at} (the last loads: {urls})", after.saturating_sub(before));
}
async fn open_counted(tab: &Tab, url: &str) {
tab.open(url).await.unwrap();
}
async fn counted_tab(browser: &Browser) -> Tab {
let tab = browser.tab().await.unwrap();
tab.page.evaluate_on_new_document(COUNT_LOADS).await.expect("the load counter");
tab
}
/// The owner's passkey, enrolled on /login with the break-glass token (the rig's first admin).
async fn first_admin(rig: &Rig, tab: &Tab) {
tab.open(&rig.at("/login")).await.unwrap();
tab.until("the bootstrap door", "!document.getElementById('reg').hidden").await;
tab.fill("#token", CONSOLE).await;
tab.js("document.getElementById('role').value = 'admin'").await.unwrap();
tab.js("document.getElementById('reg').open = true").await.unwrap();
tab.fill("#user", "owner").await;
tab.click("#register").await;
tab.until("registered", SIGNED_IN_OR_REGISTERED).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_passkey_sign_in_on_the_front_page_lands_on_the_overview_and_stays_signed_in() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let tab = counted_tab(&browser).await;
first_admin(&rig, &tab).await;
tab.until_url("the registration lands on the overview", &rig.at("/overview")).await;
stays(&tab, "after the registration").await;
let who = tab.until("the topbar names the person after the registration", TOPBAR_PERSON).await;
assert_eq!(who.as_str(), Some("owner"), "{who}");
// Signed out of this tab, then one press on the front page's Log in.
tab.js(SIGN_OUT_TAB).await.unwrap();
open_counted(&tab, &rig.at("/")).await;
tab.until("the front page's Log in", LOG_IN_READY).await;
tab.press("#enter").await;
tab.until_url("the sign-in lands on the overview", &rig.at("/overview")).await;
stays(&tab, "after the front page's sign-in").await;
let who = tab.until("the topbar names the person after the sign-in", TOPBAR_PERSON).await;
assert_eq!(who.as_str(), Some("owner"), "{who}");
assert_eq!(tab.js(TOPBAR_LOG_IN).await.unwrap(), false, "a signed-in console offers Log in:\n{}", tab.text().await);
// A reload of the overview and the pages around it: each loads once and stays.
for page in ["/overview", "/", "/login", "/account"] {
open_counted(&tab, &rig.at(page)).await;
stays(&tab, &format!("{page}, signed in")).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_mailed_sign_in_links_registration_lands_on_the_overview_and_stays_signed_in() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let admin = counted_tab(&browser).await;
first_admin(&rig, &admin).await;
let secret = rig.invite("ignalina", "holger-writers");
let person = browser.person().await.unwrap();
person.page.evaluate_on_new_document(COUNT_LOADS).await.unwrap();
person.open(&rig.at(&format!("/login?invite={secret}&user=ignalina"))).await.unwrap();
person.until("the register panel", "document.getElementById('register').textContent === 'Touch your security key'").await;
person.click("#register").await;
person.until_url("the registration lands on the overview", &rig.at("/overview")).await;
stays(&person, "after the mailed link's registration").await;
let who = person.until("the topbar names the person", TOPBAR_PERSON).await;
assert_eq!(who.as_str(), Some("ignalina"), "{who}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_cookie_holger_no_longer_takes_never_makes_the_console_bounce() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let tab = counted_tab(&browser).await;
first_admin(&rig, &tab).await;
tab.until_url("the registration lands on the overview", &rig.at("/overview")).await;
// The session ended at holger, the cookie kept (an update's restart, a logout elsewhere).
let (status, body) = rig.console_post("/-/auth/passkey/sessions/revoke-all", None);
assert_eq!(status, 200, "every session ends: {body}");
for page in ["/overview", "/", "/login"] {
open_counted(&tab, &rig.at(page)).await;
stays(&tab, &format!("{page} with an ended session's cookie")).await;
}
open_counted(&tab, &rig.at("/overview")).await;
tab.until("the console offers Log in", TOPBAR_LOG_IN).await;
// A pasted bearer that is no session at all (the mailed link's secret, pasted as a token).
let r = tab.js("fetch('/-/console/token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: 'not-a-session-0123456789abcdef' }) }).then(r => r.status)").await.unwrap();
assert_eq!(r.as_u64(), Some(204), "{r}");
for page in ["/overview", "/", "/login"] {
open_counted(&tab, &rig.at(page)).await;
stays(&tab, &format!("{page} with a cookie that is no session")).await;
}
}
/// The front page's or /login's main button, its words.
const ENTER_WORDS: &str = "(() => { const b = document.getElementById('enter'); return b && !b.disabled && b.offsetParent !== null ? b.textContent.trim() : ''; })()";
const LOGIN_WORDS: &str = "(() => { const b = document.getElementById('login'); return b && !b.disabled && b.offsetParent !== null ? b.textContent.trim() : ''; })()";
/// What the page says in its status line.
const MSG: &str = "(() => { const m = document.getElementById('msg'); return m ? m.textContent : ''; })()";
/// ★ **The front page and /login agree with the console** (HOL-LOGIN-LOOP, the cause): a person
/// signed in who comes back to https://holger.rs (typed, the console's brand link, Back) was
/// offered "Log in" again by the front page and by /login, which never asked holger whether the
/// tab holds a session. They pressed it, signed in once more, landed on the console, came back,
/// and were offered "Log in" again: login, login again. Signed in, both pages say who is signed
/// in and offer the console, with no second ceremony; signed out, both offer Log in as before.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_signed_in_person_back_on_the_front_page_or_login_is_offered_the_console_not_log_in_again() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let tab = counted_tab(&browser).await;
first_admin(&rig, &tab).await;
tab.until_url("the registration lands on the overview", &rig.at("/overview")).await;
tab.until("the topbar names the person", TOPBAR_PERSON).await;
tab.watch_ceremonies().await.unwrap();
// Back on the front page, signed in.
open_counted(&tab, &rig.at("/")).await;
tab.until("the front page has answered", "(() => { const b = document.getElementById('enter'); return !!b && !b.disabled; })()").await;
// The page asks holger once; until it has answered the button may read "Log in".
tab.until("the front page offers a signed-in person the console, not Log in again", &format!("{ENTER_WORDS} === 'Open the console'")).await;
let said = tab.js(MSG).await.unwrap();
assert!(said.as_str().is_some_and(|s| s.contains("Signed in as owner")), "the front page names who is signed in: {said}");
stays(&tab, "the front page, signed in").await;
tab.press("#enter").await;
tab.until_url("Open the console opens the console", &rig.at("/overview")).await;
stays(&tab, "the console, opened from the front page").await;
// /login, signed in (no invite, no step-up asked).
open_counted(&tab, &rig.at("/login")).await;
tab.until("/login offers a signed-in person the console, not Log in again", &format!("{LOGIN_WORDS} === 'Open the console'")).await;
let said = tab.js(MSG).await.unwrap();
assert!(said.as_str().is_some_and(|s| s.contains("Signed in as owner")), "/login names who is signed in: {said}");
stays(&tab, "/login, signed in").await;
tab.click("#login").await;
tab.until_url("Open the console on /login opens the console", &rig.at("/overview")).await;
let ceremonies = tab.ceremonies().await;
assert!(ceremonies.iter().all(|c| c["kind"] != "get"), "a signed-in person was put through a second sign-in: {ceremonies:?}");
// Signed out of this tab: both offer Log in again.
tab.js(SIGN_OUT_TAB).await.unwrap();
open_counted(&tab, &rig.at("/")).await;
tab.until("the front page's Log in, signed out", LOG_IN_READY).await;
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(tab.js(LOG_IN_READY).await.unwrap(), true, "a signed-out tab is offered the console on the front page:\n{}", tab.text().await);
open_counted(&tab, &rig.at("/login")).await;
tab.until("/login's Log in, signed out", LOGIN_WORDS).await;
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(tab.js(LOGIN_WORDS).await.unwrap().as_str(), Some("Log in"), "a signed-out tab is offered the console on /login:\n{}", tab.text().await);
}