7e89607805cac22b52143b23a45464759e66a957
braney
  Wed Sep 16 12:36:15 2026 -0700
docent: a login: step, with the account kept per hgcentral, refs #37892

hgCollection refuses a visitor who is not signed in -- hgCollection.c doMiddle,
"You must be logged in to edit collections" -- and so does the saving half of
hgSession.  The suite has never had a logged-in page, and until now could not:
the login cookie is validated against a salted hash (login.cookieSalt,
hg/lib/wikiLink.c), so there is no cookie to hand the browser.  A script that
needs one of those pages has to sign in the way a person does.

`login:` does that, through hgLogin's own form, and it takes no credentials and
cannot be given any.  They come from ~/.docentLogin (DOCENT_LOGIN_FILE overrides,
DOCENT_LOGIN_USER + DOCENT_LOGIN_PASSWORD override both for one run), which is
refused unless it is mode 0600 -- the rule hg/lib/hgConfig.c applies to hg.conf,
for the same reason.  Nothing prints a password.

THE FILE IS KEYED BY HGCENTRAL DATABASE, not by server.  An account is a row in
gbMembers in one central, the way a named session is: genome-test, hgwdev, every
hgwdev-<name> sandbox and every ticket park read hgcentraltest and share one
account, while hgwbeta reads hgcentralbeta and the RR reads hgcentral.  So the
file is one [hgcentraltest] section rather than a section per sandbox.

Which central a server reads is READ from its hg.conf, following its includes the
way hgConfig.c does, and not guessed from the host -- because a sandbox can point
itself somewhere else and two on hgwdev do today: of the personal confs there, 45
set central.db=hgcentraltest, one sets hgcentralgsid and one hgcentralbeta.  A
server whose conf is on another machine falls back to a small table (the RR, the
two mirrors, hgwbeta), and [default] catches the rest.  A run redirected with
DOCENT_TARGET looks up the server it is really driving.

Two failures the step has to tell apart, and both cost a run to find:

A WRONG PASSWORD IS A PERFECTLY GOOD PAGE.  hgLogin answers one by drawing the
same form again with a red message, so a step that just navigated on would leave
every later step running logged out and the failure would surface somewhere else
entirely.  The step fails on #accountLoginForm still being there, and quotes what
the page said.

A RIGHT PASSWORD ARRIVES MID-REDIRECT.  hgLogin answers one with a page that
navigates ITSELF a moment later: returnToURL(150) at hgLogin.c:1160 writes
setTimeout(function(){location=...}, 150).  Returning while that timer is pending
means the next step's goto: races it and the browser aborts one of the two, which
arrives as a bare net::ERR_ABORTED on a URL that is completely fine.  The step now
waits for that redirect to land.  The failure path is checked first, since that
page never leaves hgLogin and there is no redirect to wait for.

preflight reports the account as a fixture -- which central it resolved, which
section it came from, and why it is unusable if it is -- so a missing password is
caught before the browser starts.  It attempts no login: a wrong password fails
loudly at the step itself, which is the one thing preflight cannot do for it.

The hg.conf reader is now in both docent.js and tests/preflight.js, beside the
resolveTarget each of them already carries.  Both say to keep the other in step.
If that second copy is a copy too many, the two of them want a shared module.

diff --git src/hg/utils/docent/tests/preflight.js src/hg/utils/docent/tests/preflight.js
index dca3fed0c66..664c1a9ca45 100644
--- src/hg/utils/docent/tests/preflight.js
+++ src/hg/utils/docent/tests/preflight.js
@@ -103,49 +103,121 @@
   for (const raw of text.split('\n')) {
     const line = raw.trim();
     if (!line || line.startsWith('#')) continue;
     if (/^include\s/.test(line))
       readHgConf(path.resolve(path.dirname(file), line.slice(7).trim()), out, seen, depth + 1);
     else if (/^delete\s/.test(line))
       for (const name of line.slice(6).trim().split(/\s+/)) out.delete(name);
     else {
       const eq = line.indexOf('=');
       if (eq > 0) out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
     }
   }
   return out;
 }
 
+// Which hgcentral a server reads. Read from its hg.conf when that is on this machine,
+// because a sandbox may say so for itself -- 45 of the personal confs on hgwdev set
+// central.db to hgcentraltest and two do not. Otherwise a table for the servers whose
+// conf is somewhere else. docent.js carries the same lookup; keep the two in step.
+const CENTRAL_BY_HOST = {
+  'genome.ucsc.edu': 'hgcentral',
+  'genome-euro.ucsc.edu': 'hgcentral',         // its own database of the same name
+  'genome-asia.ucsc.edu': 'hgcentral',         // ... and so is this one
+  'hgwbeta.soe.ucsc.edu': 'hgcentralbeta',
+};
+function centralDbFor(server) {
+  const file = hgConfFor(server);
+  if (file) {
+    const db = readHgConf(file).get('central.db');
+    if (db) return db;
+  }
+  try { return CENTRAL_BY_HOST[new URL(server).hostname] || null; } catch (e) { return null; }
+}
+
 const PAD = '              ';
 function reportTargetConf(server) {
   console.log(`  target      ${server}`);
   const file = hgConfFor(server);
   if (!file) {
     console.log(`${PAD}not on this machine, so its hg.conf cannot be read from here`);
     return;
   }
   if (!fs.existsSync(file)) {
     console.log(`${PAD}${file} -- no such file`);
     return;
   }
   const conf = readHgConf(file);
   const w = Math.max(...HG_CONF_KEYS.map(k => k.length));
   console.log(`${PAD}${file}`);
   for (const k of HG_CONF_KEYS)
     console.log(`${PAD}${k.padEnd(w)}  ${conf.has(k) ? conf.get(k) : 'unset'}`);
 }
 
+// The credentials a `login:` step needs, resolved the same way docent.js resolves them:
+// keyed by the HGCENTRAL the server reads, because an account is a row in gbMembers in
+// one of them. genome-test, hgwdev, every sandbox and every ticket park read
+// hgcentraltest, so one account covers all of them; hgwbeta and the RR are separate sets
+// of accounts. Checked here because a missing password is exactly the kind of fixture
+// this program exists for -- the run would otherwise get as far as hgLogin before it
+// said so.
+//
+// No password, and no line of the file, is ever printed, and no login is attempted: a
+// wrong password fails loudly at the step itself, which is the one thing preflight cannot
+// do for it.
+function loginSections(text) {
+  const out = [];
+  let cur = null;
+  for (const raw of text.split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    const sec = /^\[(.+)\]$/.exec(line);
+    if (sec) { cur = { central: sec[1].trim(), user: '', password: '' }; out.push(cur); continue; }
+    const eq = line.indexOf('=');
+    if (eq < 0 || !cur) continue;
+    const k = line.slice(0, eq).trim(), v = line.slice(eq + 1).trim();
+    if (k === 'user' || k === 'password') cur[k] = v;
+  }
+  return out;
+}
+
+// Returns {label, why}: what to show for this server's account, and why it is unusable.
+// Keyed by the hgcentral the server reads, since that is where gbMembers lives.
+function loginAccount(server) {
+  const env = process.env;
+  if (env.DOCENT_LOGIN_USER && env.DOCENT_LOGIN_PASSWORD)
+    return { label: `${env.DOCENT_LOGIN_USER} (from the environment)`, why: null };
+  const central = centralDbFor(server);
+  const where = central ? `central.db ${central}` : 'central.db unknown';
+  const file = env.DOCENT_LOGIN_FILE || path.join(os.homedir(), '.docentLogin');
+  let text;
+  try { text = fs.readFileSync(file, 'utf8'); }
+  catch (e) { return { label: where, why: `no ${file}, and no DOCENT_LOGIN_USER/PASSWORD` }; }
+  const perm = fs.statSync(file).mode & 0o777;
+  if (perm & 0o077)
+    return { label: where, why: `${file} is readable by group or other (mode ${perm.toString(8)}); chmod 600 it` };
+  const secs = loginSections(text);
+  const match = (central && secs.find(x => x.central === central))
+             || secs.find(x => x.central === 'default');
+  if (!match)
+    return { label: where, why: `${file} has no section for ${where}`
+      + (secs.length ? ` (it has ${secs.map(x => `[${x.central}]`).join(' ')})` : ' (it has no [section] at all)') };
+  if (!match.user || !match.password)
+    return { label: `[${match.central}]`, why: `[${match.central}] in ${file} needs a "user=" and a "password=" line` };
+  return { label: `${match.user} (from [${match.central}], ${where})`, why: null };
+}
+
 const fixtures = [];
 const add = f => fixtures.push(f);
 
 async function get(url) {
   const r = await fetch(url, { redirect: 'follow' });
   return { status: r.status, body: await r.text() };
 }
 
 // A session is present when the page does NOT carry hgSession's own not-found message.
 // Requiring a track image instead would be wrong: a session may legitimately restore a
 // view with every track hidden.
 function sessionCheck(server, user, name) {
   const url = `${server}/hgTracks?hgS_doOtherUser=submit`
     + `&hgS_otherUserName=${enc(user)}&hgS_otherUserSessionName=${enc(name)}`;
   return async () => {
@@ -200,30 +272,35 @@
     if (verb === 'loadSession') {
       if (o && o.user && o.name) {
         add({ script: f, kind: 'session', label: `${o.user}/${o.name}`,
               check: sessionCheck(server, o.user, o.name) });
       } else if (o && o.file) {
         // A session: step earlier in the same script writes this, so it is only a
         // fixture when nothing here creates it.
         const writes = (doc.steps || []).some(s => s && typeof s === 'object' && s.session === o.file);
         if (!writes) {
           add({ script: f, kind: 'file', label: o.file,
                 check: fileCheck(path.join(DIR, 'sessions', base, `${o.file}.txt`)) });
         }
       } else if (typeof arg === 'string' && /^https?:/.test(arg)) {
         add({ script: f, kind: 'session-url', label: arg, check: urlCheck(arg) });
       }
+    } else if (verb === 'login') {
+      // Keyed by server, so a directory pointed at two of them reports two accounts.
+      const acct = loginAccount(server);
+      add({ script: f, kind: 'login', label: `${acct.label} on ${server}`,
+            check: async () => acct.why });
     } else if (verb === 'goto' && typeof arg === 'string') {
       // A hub can also arrive inside a goto: URL, which is the only way to write a test
       // about the genome= form (the hub: verb builds db=). Pull hubUrl out of the query
       // so those hubs are checked too, rather than being invisible to preflight because
       // of how the step happens to be spelled.
       const m = /[?&]hubUrl=([^&]+)/.exec(arg);
       if (m) {
         const url = decodeURIComponent(m[1]);
         add({ script: f, kind: 'hub', label: url, check: urlCheck(url, 'hub') });
       }
     } else if (verb === 'hub' || verb === 'addHub') {
       const url = (typeof arg === 'string') ? arg : (o && o.url);
       // A hub.txt replaced by a directory listing or an error page still answers 200, so
       // require the one word every hub.txt has to contain.
       if (url) add({ script: f, kind: 'hub', label: url, check: urlCheck(url, 'hub') });