1fbda5badde574c10884e5339fdda1f5b7495990
braney
  Thu Sep 24 13:45:30 2026 -0700
docent: regression script for the Sessions page Replace keeping who can load it, refs #38311

rm38311 saves a private session from the save card, saves over it with the
private box unticked, and checks that the row still has its lock.  It passes on
hgwbeta (v504) and genome-test, and fails on hgw0 (v503).

Docent changes it needed:
- a fill: verb, to type into any form field
- login: no longer dies when a navigation is still under way during its
bad-password check, which is what broke it on ticket parks
- a park is driven on its https port, because hgLogin posts its form to
https:// on the port it was reached on; the parks' self-signed certificate
is accepted on loopback targets only
- hgw0 maps to hgcentral, and a park's https port finds its hg.conf

diff --git src/hg/utils/docent/targetConf.js src/hg/utils/docent/targetConf.js
index 3443057b4f0..e34e1b2159b 100644
--- src/hg/utils/docent/targetConf.js
+++ src/hg/utils/docent/targetConf.js
@@ -1,197 +1,200 @@
 /* targetConf.js -- where a Docent run is pointed, and what that server is configured like.
  *
  * Required by docent.js and by tests/preflight.js. It exists because those two have to
  * agree: preflight checks the fixtures for the server the run will actually drive, and a
  * run that resolved its target, its hg.conf or its account even slightly differently
  * would be checked against the wrong machine. They used to carry a copy each.
  *
  * Four questions, in order, each answered from the one before:
  *
  *   resolveTarget   a `target:` (or DOCENT_TARGET) -> the .../cgi-bin URL to drive
  *   hgConfFor       that URL -> the hg.conf it reads, when the server is on this machine
  *   centralDbFor    that conf -> which hgcentral it uses
  *   loginLookup     that central -> the account to sign in with
  *
  * Nothing here opens a browser or the network: it is file reading and string work, which
  * is why preflight can ask all of it in the seconds before a run.
  */
 'use strict';
 const fs = require('fs');
 const os = require('os');
 const path = require('path');
 
 // `target:` takes a shorthand from this table, a bare `hgwdev-<user>` sandbox name, or a
 // full https://.../cgi-bin URL. Default is genome-test, so a script that forgets to say
 // where it runs does not silently hit someone's sandbox.
 const SERVERS = {
   'rr': 'https://genome.ucsc.edu/cgi-bin',
   'genome-test': 'https://genome-test.gi.ucsc.edu/cgi-bin',
   'hgwdev': 'https://hgwdev.gi.ucsc.edu/cgi-bin',
   'hgwbeta': 'https://hgwbeta.soe.ucsc.edu/cgi-bin',
 };
 
 function resolveTarget(t) {
   if (!t) return SERVERS['genome-test'];
   if (SERVERS[t]) return SERVERS[t];
   if (/^hgwdev-[a-z0-9._-]+$/i.test(t)) return `https://${t}.gi.ucsc.edu/cgi-bin`;  // sandbox
   return t;                                                                         // full URL
 }
 
 // The server a run drives: DOCENT_TARGET wins over the script's own `target:`, so a suite
 // written against one server can be pointed at another without editing the scripts.
 function serverFor(target) {
   return resolveTarget(process.env.DOCENT_TARGET || target).replace(/\/$/, '');
 }
 
 // Which hg.conf that server reads, when it runs on THIS machine. There is no way to ask a
 // browser over http what its hg.conf says, so this is a lookup by convention rather than a
 // measurement, and it answers null for anything off this host -- hgwbeta, the RR, a
 // colleague's machine.
 function hgConfFor(server) {
   let u;
   try { u = new URL(server); } catch (e) { return null; }
   const host = u.hostname;                            // 127.0.0.1 keeps its dots
   const name = host.split('.')[0];                    // hgwdev-braney out of the FQDN
   if (name === 'hgwdev' || name === 'genome-test') return '/usr/local/apache/cgi-bin/hg.conf';
   if (name.startsWith('hgwdev-'))                     // a sandbox or a demo browser
     return `/usr/local/apache/cgi-bin-${name.slice('hgwdev-'.length)}/hg.conf`;
   if ((host === '127.0.0.1' || host === 'localhost') && u.port) {
     // A ticket park from `ts`: its port is in the registry, and the frozen hg.conf sits
     // under the ticket's own directory. A park is the one target whose conf is NOT the
     // live sandbox's, which is the whole reason for parking it.
     const root = process.env.TS_ROOT || path.join(os.homedir(), 'ticketSandboxes');
     let reg;
     try { reg = fs.readFileSync(path.join(root, 'ports.tsv'), 'utf8'); } catch (e) { return null; }
+    // The registry holds the http port; the same park answers https on that port + 1000.
+    const httpPort = String(u.protocol === 'https:' ? Number(u.port) - 1000 : Number(u.port));
     for (const line of reg.split('\n')) {
       const f = line.split('\t');
-      if (f[1] === u.port) return path.join(root, f[0], 'cgi-bin', 'hg.conf');
+      if (f[1] === httpPort) return path.join(root, f[0], 'cgi-bin', 'hg.conf');
     }
   }
   return null;
 }
 
 // hg.conf as hg/lib/hgConfig.c reads it: `include` pulls another file in relative to the
 // including one, `delete` drops a name, and a later assignment wins over an earlier one
 // (parseConfigLine hashAdds and cfgOption reads the most recently added). So the value
 // this returns is the EFFECTIVE one -- a sandbox conf that sets nothing still shows what
 // it inherits from the shared conf it includes.
 //
 // Callers print only the settings they name. The includes lead to hg.conf.private, which
 // holds database passwords.
 function readHgConf(file, out = new Map(), seen = new Set(), depth = 0) {
   if (depth > 10 || seen.has(file)) return out;
   seen.add(file);
   let text;
   try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return out; }
   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;
 }
 
 // Servers whose hg.conf is on another machine, so it cannot be read and has to be known.
 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',
+  'hgw0.soe.ucsc.edu': 'hgcentral',            // the RR node that takes a release first
 };
 
 // Which hgcentral a server reads. Read from its hg.conf wherever that is possible, because
 // a sandbox may say so for itself: of the personal confs on hgwdev today, 45 set
 // central.db to hgcentraltest and two do not (hgcentralgsid, 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; }
 }
 
 // ---------- the account a `login:` step signs in with ----------
 // Keyed by hgcentral DATABASE, not by server: an account is a row in gbMembers in one of
 // them, the way a named session is. genome-test, hgwdev, every hgwdev-<name> sandbox and
 // every ticket park read hgcentraltest and share one account; hgwbeta reads hgcentralbeta
 // and the RR reads hgcentral.
 function loginFile() {
   return process.env.DOCENT_LOGIN_FILE || path.join(os.homedir(), '.docentLogin');
 }
 
 // Parse the sectioned file into [{central, user, password}], in file order. Lines before
 // any [section] are ignored rather than treated as a default: an unsectioned file is one
 // written against the older single-account form, and silently using it everywhere is how
 // an hgcentraltest password would reach the RR.
 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;
 }
 
 // The one place that decides which account a server gets, so the run and the fixture check
 // cannot disagree about it. Returns the facts and, when it cannot be used, one sentence
 // saying why -- that sentence is the substantive half and is shared; each caller phrases
 // its own success line, since one logs and the other prints a fixture row.
 //
 //   { central, user, password, section, source, why }
 //
 // `section` is the heading it matched ('hgcentraltest', 'default'), `source` the file it
 // came from or 'the environment'. On failure user and password are null and `why` is set.
 // No password is returned to anything that prints, and none is ever logged.
 function loginLookup(server) {
   const env = process.env;
   const central = centralDbFor(server);
   const where = central ? `central.db ${central}` : 'central.db unknown';
   const fail = why => ({ central, user: null, password: null, section: null, source: null, why });
   // A single-run override, for trying an account without writing it down. It applies to
   // whatever server this run drives, which is why it wins over the file.
   if (env.DOCENT_LOGIN_USER && env.DOCENT_LOGIN_PASSWORD)
     return { central, user: env.DOCENT_LOGIN_USER, password: env.DOCENT_LOGIN_PASSWORD,
              section: null, source: 'the environment', why: null };
   const file = loginFile();
   let text;
   try { text = fs.readFileSync(file, 'utf8'); }
   catch (e) {
     return fail(`no ${file}, and no DOCENT_LOGIN_USER/DOCENT_LOGIN_PASSWORD. Write it with a `
       + `[<hgcentral database>] section holding "user=" and "password=" lines (mode 0600)`);
   }
   // Refuse a file anyone else can read, the way hg/lib/hgConfig.c checkConfigPerms refuses
   // a group- or world-readable hg.conf. A test that quietly used a readable password file
   // would make one on every machine it ran on.
   const perm = fs.statSync(file).mode & 0o777;
   if (perm & 0o077)
     return fail(`${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 fail(`${file} has no section for ${where}`
       + (secs.length ? ` (it has ${secs.map(x => `[${x.central}]`).join(' ')})` : ' (it has no [section] at all)')
       + '; add one, or a [default]');
   if (!match.user || !match.password)
     return fail(`[${match.central}] in ${file} needs a "user=" and a "password=" line`);
   return { central, user: match.user, password: match.password,
            section: match.central, source: file, why: null };
 }
 
 module.exports = { SERVERS, resolveTarget, serverFor, hgConfFor, readHgConf,
                    CENTRAL_BY_HOST, centralDbFor, loginFile, loginSections, loginLookup };