Files
Project-SDE-WP-Suite/tests/launcher_check.py
Matt Mabrey 73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00

403 lines
18 KiB
Python

#!/usr/bin/env python3
"""The launcher's project entry points — B3 (T5.2).
B3's warning is about ORDER: the proposal removes the project-picker card, and
the first-run empty state was built inside it. Remove the card first and every
brand-new account lands on a page whose only instruction is to choose from a list
with nothing in it.
So the state that matters most here is the one the fixture does not naturally
produce — an account with zero projects — and it is tested against a database
seeded with none, not by hiding rows in the browser.
1. a brand-new account with zero projects sees a clear path to create one
2. the sample project stays discoverable from that empty state
3. the picker card is gone, and nothing on the page re-creates it
4. switching projects still works from the app bar for users who have projects
5. creating a project from the launcher still works, end to end
6. the rebuilt form is accessible: labelled, keyboard-reachable, and its
validation lands at the field rather than in a native dialog (C1)
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
STUB = """
window.__dialogs = [];
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
true
"""
READY = "!!(document.getElementById('proj-status') && !document.querySelector('.proj-loading'))"
def seed_empty(db_path):
"""One account, no projects at all. This is the state B3 is about, and no
existing fixture has it — browser_check's seeds two projects precisely so the
scoping assertions have something to scope."""
os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
os.environ.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
from server.db import SessionLocal, Base, engine
from server import models, auth
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
db.add(models.User(id="user_new", username="new", email="new@example.test",
full_name="New Starter", role=auth.ROLE_ADMIN))
db.commit()
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
def settle(seconds=1.4):
time.sleep(seconds)
def js_errors(page):
"""page.js_errors() minus one known false alarm.
A project with no SOP yet answers GET /api/sops/latest with 404, correctly —
and the browser logs every 404 resource at error level whatever the app does
about it. browser_check.py's fixture seeds a SOP to sidestep this; a probe
that CREATES a project cannot, because a brand-new project has no SOP by
definition. That is exactly the state under test, so the entry is filtered by
name rather than the check being dropped."""
return [e for e in page.js_errors() if "/api/sops/latest" not in e]
def visible(page, sel):
return page.eval(
"(() => { const e = document.querySelector(%r); if (!e) return false; "
"const r = e.getBoundingClientRect(); return !!(r.width && r.height); })()" % sel)
def run_empty(page, base, tok):
print("\n1 + 2. a brand-new account with no projects at all")
page.clear_cookies()
page.set_cookie("wp_session", tok["new"])
page.goto(base + "/index.html", READY)
settle(1.6)
page.eval(STUB)
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("it says there are no projects yet", visible(page, "#first-run"))
chk("...and explains what a project is for, not just that there are none",
len((page.eval("(document.getElementById('first-run')||{}).textContent||''")).strip()) > 120)
chk("the create form is open, not behind another click",
visible(page, "#new-project") and visible(page, "#np_name"))
chk("...headed as the first one", "first" in (page.eval(
"(document.getElementById('new-project-head')||{}).textContent||''")).lower(),
page.eval("(document.getElementById('new-project-head')||{}).textContent||''"))
chk("...with no Cancel, because there is nothing to cancel back to",
not visible(page, "#np-cancel"))
chk("the hero says create, not select",
"reate" in (page.eval("(document.getElementById('hero-sub')||{}).textContent||''")),
page.eval("(document.getElementById('hero-sub')||{}).textContent||''"))
chk("the sample project is offered from the empty state", visible(page, "#sample-offer"))
chk("...as a button that says what it does",
"sample" in (page.eval(
"(document.getElementById('use-sample-btn')||{}).textContent||''")).lower())
chk("no tool cards are shown over a project that does not exist",
not visible(page, "#overview"))
chk("...and no 'choose a project' prompt with nothing to choose from",
not visible(page, "#pick-prompt"))
print("\n6. the rebuilt create form is accessible (C1)")
labelled = json.loads(page.eval("""JSON.stringify(
[...document.querySelectorAll('#np-form input')].map(i => ({
id: i.id,
labelled: !!(i.labels && i.labels.length),
describedby: i.getAttribute('aria-describedby'),
tabindex: i.tabIndex,
})))"""))
chk("every input has a real label", labelled and all(f["labelled"] for f in labelled),
[f for f in labelled if not f["labelled"]])
chk("...and every one is keyboard reachable",
labelled and all(f["tabindex"] >= 0 for f in labelled))
chk("the required field points at its error region",
any(f["id"] == "np_name" and f["describedby"] == "np_name_err" for f in labelled),
labelled)
ring = page.eval("""(() => {
const i = document.getElementById('np_name');
i.focus();
if (document.activeElement !== i) return 'not focusable';
const cs = getComputedStyle(i);
return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0
? 'ring ' + cs.outlineWidth + ' ' + cs.outlineColor : 'no ring';
})()""")
chk("...and shows a focus ring when focused (BL-014's third site)",
str(ring).startswith("ring"), ring)
print(" submitting it empty says so at the field, not in a dialog")
page.eval("document.getElementById('np-form').requestSubmit()")
settle(0.6)
chk("an empty submit is refused", visible(page, "#first-run"))
chk("...with the reason at the field",
bool((page.eval("(document.getElementById('np_name_err')||{}).textContent||''")).strip()),
page.eval("(document.getElementById('np_name_err')||{}).textContent||''"))
chk("...announced through a live region",
page.eval("(document.getElementById('np_name_err')||{}).getAttribute('role')") == "alert")
chk("...the field marked invalid",
page.eval("document.getElementById('np_name').getAttribute('aria-invalid')") == "true")
chk("...focus moved to it",
page.eval("(document.activeElement||{}).id") == "np_name")
chk("...and no native dialog was opened",
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
page.eval("JSON.stringify(window.__dialogs||[])"))
print("\n5. creating the first project works end to end")
page.eval("""(() => {
const n = document.getElementById('np_name');
n.value = 'First Job'; n.dispatchEvent(new Event('input', {bubbles:true}));
document.getElementById('np_number').value = 'FJ-1';
return true;
})()""")
page.eval("document.getElementById('np-form').requestSubmit()")
for _ in range(30):
if page.eval("!document.getElementById('overview') || "
"getComputedStyle(document.getElementById('overview')).display !== 'none'"):
break
time.sleep(0.3)
settle(1.2)
chk("the new project becomes the active one",
page.eval("(ProjectData.getActive()||{}).name") == "First Job",
page.eval("JSON.stringify(ProjectData.getActive()||{})"))
chk("...the tool cards appear", visible(page, "#overview"))
chk("...the first-run state stands down", not visible(page, "#first-run"))
chk("...and so does the sample offer", not visible(page, "#sample-offer"))
chk("...the hero names the project",
page.eval("(document.getElementById('hero-title')||{}).textContent") == "First Job")
chk("...and the URL carries it, so the address bar is shareable (S3)",
"project=" in page.eval("location.search"), page.eval("location.search"))
chk("no JavaScript error through the whole first-run flow",
not js_errors(page), js_errors(page))
print("\n2b. the sample project is reachable from the empty state")
page.eval("localStorage.clear()")
page.goto(base + "/index.html", READY)
settle(1.6)
page.eval(STUB)
# One project exists now, so this is the "projects exist, none active" state.
chk("with a project on the account but none chosen, the launcher says to choose",
visible(page, "#pick-prompt"))
chk("...and points at the app bar's switcher, not at a second list on the page",
"switcher" in (page.eval(
"(document.getElementById('pick-prompt-sub')||{}).textContent||''")).lower())
chk("...offering New project as well", visible(page, "#new-project-btn"))
chk("...with the create form closed until asked for", not visible(page, "#new-project"))
chk("...and the first-run state not shown to someone who is not new",
not visible(page, "#first-run"))
page.eval("document.getElementById('new-project-btn').click()")
settle(0.6)
chk("New project opens the form", visible(page, "#new-project"))
chk("...and moves focus into it",
page.eval("(document.activeElement||{}).id") == "np_name")
chk("...with Cancel available now that there is something to cancel to",
visible(page, "#np-cancel"))
page.eval("document.getElementById('np-cancel').click()")
settle(0.5)
chk("Cancel closes it again", not visible(page, "#new-project"))
chk("...and brings the choose-a-project prompt back", visible(page, "#pick-prompt"))
def run_populated(page, base, tok):
print("\n3 + 4. the picker card is gone; the app bar still switches")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/index.html?project=projA", READY)
settle(1.8)
page.eval(STUB)
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
chk("the picker card is gone", page.eval("!document.getElementById('project-section')"))
chk("...and so is its dropdown", page.eval("!document.getElementById('project-select')"))
chk("...and nothing else on the page lists projects to pick from",
page.eval("""(() => {
const inMain = [...document.querySelectorAll('.container select')];
return inMain.filter(s => s.options.length > 1).length === 0;
})()"""),
page.eval("[...document.querySelectorAll('.container select')].map(s => s.id)"))
chk("the app bar carries a project switcher",
page.eval("!!document.querySelector('.wp-appbar .wpc-proj-btn')"))
chk("...naming the active project",
"Job A" in (page.eval(
"(document.querySelector('.wpc-proj-name')||{}).textContent||''")),
page.eval("(document.querySelector('.wpc-proj-name')||{}).textContent||''"))
page.eval("document.querySelector('.wpc-proj-btn').click()")
settle(0.6)
chk("...it opens", page.eval(
"document.querySelector('.wpc-proj-btn').getAttribute('aria-expanded')") == "true")
chk("...and lists both projects on the account",
page.eval("document.querySelectorAll('.wpc-pop .wpc-item').length") == 2,
page.eval("document.querySelectorAll('.wpc-pop .wpc-item').length"))
chk("...its footer offers a new project rather than a list that moved",
"New project" in (page.eval(
"(document.querySelector('.wpc-pop-foot')||{}).textContent||''")),
page.eval("(document.querySelector('.wpc-pop-foot')||{}).textContent||''"))
chk("...pointing at the launcher's form",
"#new-project" in (page.eval(
"(document.querySelector('.wpc-foot-btn')||{}).getAttribute('href')||''") or ""),
page.eval("(document.querySelector('.wpc-foot-btn')||{}).getAttribute('href')||''"))
page.eval("""[...document.querySelectorAll('.wpc-pop .wpc-item')]
.find(b => /Job B/.test(b.textContent)).click()""")
for _ in range(30):
if "projB" in page.eval("location.search"):
break
time.sleep(0.3)
settle(1.6)
chk("switching from the bar changes project", "projB" in page.eval("location.search"),
page.eval("location.search"))
chk("...and the launcher follows it",
page.eval("(document.getElementById('hero-title')||{}).textContent") == "Job B",
page.eval("(document.getElementById('hero-title')||{}).textContent"))
chk("...with the tool cards still shown", visible(page, "#overview"))
chk("...and no first-run or choose-a-project state on a page that has both",
not visible(page, "#first-run") and not visible(page, "#pick-prompt"))
print("\n the app bar's 'New project' link lands on the form")
page.goto(base + "/index.html#new-project", READY)
settle(1.6)
chk("arriving at #new-project opens the create form", visible(page, "#new-project"))
chk("...and not the first-run state, on an account that has projects",
not visible(page, "#first-run"))
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
page.goto(base + "/index.html?project=projA", READY)
settle(1.4)
chk("%s: the page does not scroll sideways" % label,
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
chk("%s: the switcher is reachable" % label,
page.eval("""(() => { const b = document.querySelector('.wpc-proj-btn');
if (!b) return false; const r = b.getBoundingClientRect();
return r.width > 0 && r.right <= window.innerWidth + 1; })()"""))
page.viewport(1400, 1000)
def one_run(seeder, body, title):
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-launcher-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seeder(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\n%s\nTarget: %s" % (title, base))
browser = cdp.Browser(exe)
page = browser.page()
# Trap 5, in reverse: without focus emulation the headless document is
# not the focused one, :focus-visible never matches, and every control
# reports NO ring — a false red where a11y_check.py would get a false
# green. Same switch, same reason.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
try:
body(page, base, tok)
finally:
page.close()
browser.close()
finally:
if server:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
try:
from server.db import engine
engine.dispose()
except Exception:
pass
import shutil
for _ in range(10):
shutil.rmtree(tmpdir, ignore_errors=True)
if not os.path.exists(tmpdir):
break
time.sleep(0.3)
return 0
PHASES = {
"empty": (seed_empty, run_empty, "Launcher — a brand-new account (B3)"),
"populated": (seed, run_populated, "Launcher — an account with projects (B3)"),
}
def phase_main(name):
seeder, body, title = PHASES[name]
rc = one_run(seeder, body, title)
if rc == 2:
return 2
total = len(_PASS) + len(_FAIL)
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
for f in _FAIL:
print(" - " + f)
print("RESULT %d %d" % (len(_PASS), total))
return 1 if _FAIL else 0
def main():
# The two states are DATABASE states, not view states — faking "no projects"
# in the browser would test the fake — so each phase needs its own database.
#
# And each needs its own PROCESS. server/db.py builds its engine at import
# time from DATABASE_URL, so a second seed() in the same interpreter still
# points at the first phase's database, which has been deleted by then. That
# surfaces as "unable to open database file", which reads like a broken test
# environment rather than what it is.
if len(sys.argv) > 1 and sys.argv[1] in PHASES:
return phase_main(sys.argv[1])
passed = total = 0
worst = 0
for name in ("empty", "populated"):
proc = subprocess.run([sys.executable, os.path.abspath(__file__), name],
capture_output=True, text=True,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in proc.stdout.splitlines():
if line.startswith("RESULT "):
_, p, t = line.split()
passed += int(p)
total += int(t)
continue
print(line)
if proc.stderr.strip():
print(proc.stderr.strip()[-2000:])
worst = max(worst, proc.returncode)
# A fresh browser per phase, and a pause between: started back to back
# they exhaust the headless browser's ports and abort with "browser would
# not start", which looks like a code fault and is not one.
time.sleep(2.0)
print("\n%s\n%d/%d checks passed." % ("=" * 54, passed, total))
if worst:
return worst
print("\nResult: " + _c("ALL PASS — new accounts have a way in; switching moved to the bar.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())