Files
Project-SDE-WP-Suite/tests/pipeline_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

383 lines
18 KiB
Python

#!/usr/bin/env python3
"""The launcher's pipeline strip — B4 surface (T5.3).
T4.1 put the counts on the server; this is the surface that shows them, and the
failure it has to avoid is the one B4 names: a per-browser number that looks
authoritative. So the strip is checked the way aggregates_check.py checks the
dashboard — by POISONING localStorage with different numbers and demanding the
strip still report the server's.
1. every number comes from a server endpoint
2. each cell links to a filtered view via a shareable URL, and the filter
actually applies at the other end
3. a project with zero work packages renders a sentence, not four zeros
4. the strip announces updates via aria-live, because it refreshes in place
5. a failed request is an error, not four zeros and not a remembered number
Check 2 used to be the one with a seam in it: the dashboard was an iframe CHILD
of the SOP page, so the filter had to cross that boundary and the probe read it
inside the frame. B7/T7.1 dissolved the frame and the cell links straight at the
creator, so the filter arrives in the page's own URL. The check still reads the
rendered board rather than trusting that the URL was built.
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
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
"#pipeline-strip .pipe-error')"
CELLS_JS = r"""
JSON.stringify([...document.querySelectorAll('#pipeline-strip .pipe-cell')].map(a => ({
href: a.getAttribute('href'),
num: (a.querySelector('.pipe-num') || {}).textContent,
label: ((a.querySelector('.pipe-label') || {}).textContent || '').trim(),
sub: ((a.querySelector('.pipe-sub') || {}).textContent || '').trim(),
accent: getComputedStyle(a).borderLeftColor,
tag: a.tagName,
})))
"""
def settle(seconds=1.4):
time.sleep(seconds)
# The shape ProjectData.pushSOP writes: { sop, state }. browser_check's fixture
# stores a bare {governance: …} blob, which is enough for the field view but NOT
# for the SOP wizard — restoreSavedSOP() needs `state` and bails without it, so
# the Work Package tab shows its "complete the SOP first" gate and a pipeline link
# lands on a locked door. Production data has both keys; the fixture should too,
# or the probe is testing a shape no real project has.
SOP_DATA = {
"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
"governance": {"disciplines": ["Mechanical", "Electrical"],
"woFormat": "WP##-[TYPE]"},
"woTypes": [{"name": "Conduit Install", "enabled": True}]},
"state": {"bimEnabled": False,
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
"division": "Internal", "site": "QA Lab"},
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamMembers": [],
"signoffRoles": [{"role": "Superintendent", "name": ""},
{"role": "Foreman", "name": ""}],
"wpTypes": [{"name": "Conduit Install", "enabled": True, "notes": "",
"approval": "", "specSection": ""}],
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
"disciplines": ["Mechanical", "Electrical"],
"discMode": "choice", "instanceSuffix": "letter",
"sizeHoursMax": ""},
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
"trackingUrl": "", "commissioningUrl": ""},
"constraints": [], "sequence": [], "sources": []},
}
def seed_two_projects(db_path):
"""browser_check's fixture, plus a third project with no work packages at all
— the empty state is a database state and cannot be faked in the browser."""
tok = seed(db_path)
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
sop = db.get(models.Sop, "sopA")
if sop:
sop.data = SOP_DATA
db.add(models.Project(id="projEmpty", name="Empty Job", number="E-1",
client="Internal QA"))
db.flush()
db.add(models.ProjectMember(id="pmE", user_id="user_root", project_id="projEmpty",
role=""))
# One package that is genuinely overdue and one on hold, so three of the
# four cells are non-zero and a strip that hardcoded them would show.
db.add(models.WorkPackage(
id="wpA3", project_id="projA", sop_id="sopA", number="WP03-HOLD",
subject="held for access", status="Issue", type="Conduit Install",
data={"disciplines": ["Electrical"], "hours": "8",
"constraints": [{"name": "Access", "status": "open", "comment": "no permit"}]}))
db.add(models.WorkPackage(
id="wpA4", project_id="projA", sop_id="sopA", number="WP04-LATE",
subject="late wire pull", status="In Progress", type="Conduit Install",
data={"disciplines": ["Electrical"], "hours": "12", "due": "2020-01-01",
"constraints": []}))
db.commit()
return tok
def server_metrics(page, base, project_id):
return json.loads(page.eval(
"fetch('/api/wps/metrics?project_id=%s', {headers:{Accept:'application/json'}})"
".then(r => r.text())" % project_id))
def run(page, base, tok):
def visit(path, wait=READY):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + path, wait)
settle(1.6)
print("\n1. every number comes from the server")
visit("/index.html?project=projA")
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
truth = server_metrics(page, base, "projA")
cells = json.loads(page.eval(CELLS_JS))
chk("the strip renders four cells", len(cells) == 4, [c["label"] for c in cells])
want = [("Work packages", truth["total"]), ("Release ready", truth["release_ready"]),
("On hold", truth["on_hold"]), ("Overdue", truth["overdue"])]
for (label, n), cell in zip(want, cells):
chk("%-14s reads %s, the server's own number" % (label, n),
cell["label"] == label and cell["num"] == str(n),
"cell %r = %r, server = %r" % (cell["label"], cell["num"], n))
chk("...and the fixture is not all zeros, so this proves something",
truth["total"] > 0 and truth["on_hold"] > 0 and truth["overdue"] > 0, truth)
print("\n1b. a poisoned cache does not move a single number")
page.eval("""(() => {
// Every shape the launcher has ever cached work packages under, filled with
// numbers nothing like the server's.
const fake = Array.from({length: 99}, (_, i) => ({id: 'x' + i, status: 'Closed'}));
for (const k of ['wp_packages', 'wp_packages__projA', 'wp_suite_wps',
'wp_suite_wps__projA', 'wp_creation_packages']) {
localStorage.setItem(k, JSON.stringify(fake));
}
return true;
})()""")
page.goto(base + "/index.html?project=projA", READY)
settle(1.6)
poisoned = json.loads(page.eval(CELLS_JS))
chk("with 99 fake packages in localStorage the total is still the server's %d"
% truth["total"],
poisoned and poisoned[0]["num"] == str(truth["total"]),
poisoned[0]["num"] if poisoned else poisoned)
chk("...and no cell reads 99", all(c["num"] != "99" for c in poisoned),
[c["num"] for c in poisoned])
page.eval("localStorage.clear(); true")
print("\n4. the strip announces, and says when it is working")
chk("the strip is a live region", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-live')") == "polite")
chk("...polite, not assertive — a count is not an interruption", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-live')") != "assertive")
chk("...and reports aria-busy=false once the numbers land", page.eval(
"document.getElementById('pipeline-strip').getAttribute('aria-busy')") == "false")
chk("the section is named", page.eval(
"!!document.querySelector('#pipeline[aria-labelledby]')"))
print("\n the four cells are told apart by more than a colour (C1)")
chk("every cell carries a label in words",
all(c["label"] for c in poisoned), [c["label"] for c in poisoned])
chk("...and a sentence saying what it counts",
all(len(c["sub"]) > 8 for c in poisoned), [c["sub"] for c in poisoned])
chk("...four distinct labels", len({c["label"] for c in poisoned}) == 4)
chk("...and four distinct accents, as a second channel",
len({c["accent"] for c in poisoned}) == 4, [c["accent"] for c in poisoned])
print("\n2. each cell is a shareable link into a filtered board")
chk("every cell is a real <a>, not a div with a handler",
all(c["tag"] == "A" for c in poisoned), [c["tag"] for c in poisoned])
hrefs = [c["href"] for c in poisoned]
chk("...all pointing at the dashboard",
all("view=dashboard" in h for h in hrefs), hrefs)
chk("...all carrying the project, so the link works on a cold browser",
all("project=projA" in h for h in hrefs), hrefs)
chk("...three of them naming a filter, the total naming none",
[("flag=" in h) for h in hrefs] == [False, True, True, True], hrefs)
chk("...and the filters are the ones the dashboard knows",
sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h)
== ["onhold", "overdue", "ready"], hrefs)
print(" ...and the filter arrives, on a page rather than in a frame")
onhold_href = [h for h in hrefs if "flag=onhold" in h][0]
page.goto(base + "/" + onhold_href.lstrip("/"))
for _ in range(40):
if page.eval("!!window.wpCreatorReady"):
break
time.sleep(0.3)
settle(2.0)
# This used to reach through `#wp-frame`.contentDocument, and had to: the
# dashboard was an iframe CHILD of the SOP page, so the filter was handed
# across rather than read from the child's own URL, and the only honest place
# to check it was inside the frame. B7/T7.1 dissolved the frame - the link now
# opens the creator directly and the filter arrives in its own URL, so this is
# an ordinary read of an ordinary page.
#
# What has NOT changed is why the BOARD is read rather than the globals:
# `dashFilter` and `currentView` are declared with let in a classic script, so
# they are not properties of window. The board itself is still the evidence.
inner = json.loads(page.eval("""(() => {
try {
const dv = document.getElementById('dashboard-view');
const active = document.querySelector('.dash-metric.dm-active .dm-label');
// The board only - NOT the gating panel next to it, which is server-derived
// and not filtered. Counting both would let a two-package gating list pass
// this check whatever the filter did.
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim());
});
const rows = panel
? [...panel.querySelectorAll('tbody tr')]
.map(r => (r.cells[0] ? r.cells[0].textContent : '').trim()).filter(Boolean)
: null;
return JSON.stringify({
shown: !!dv && getComputedStyle(dv).display !== 'none',
active: active ? active.textContent.trim() : null,
rows: rows,
framed: !!document.getElementById('wp-frame'),
});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()"""))
chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner)
chk("...with the on-hold tile already the active filter",
(inner.get("active") or "").lower() == "on hold", inner)
chk("...and the board listing only the held package, not all four",
inner.get("rows") == ["WP03-HOLD"], inner)
chk("...and no iframe was involved (B7/T7.1)", inner.get("framed") is False, inner)
print("\n3. a project with no work packages says so, in a sentence")
visit("/index.html?project=projEmpty")
chk("no cells are rendered", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
empty_txt = page.eval("(document.querySelector('#pipeline-strip .pipe-empty')||{}).textContent||''")
chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt))
chk("...naming the situation rather than showing four zeros",
"no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90]))
# Re-pointed at T7.1, not relaxed. The link said "Open the creator" and went to
# the suite page, because the creator was a tab of it. It is a page now, so the
# link goes there directly - and the check still demands a link to the place
# the sentence offers, rather than merely a link.
chk("...and offering somewhere to go, which is the creator", page.eval(
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"wp-creation-index\"]')"),
page.eval("(document.querySelector('#pipeline-strip .pipe-empty a')||{}).getAttribute"
" ? document.querySelector('#pipeline-strip .pipe-empty a').getAttribute('href')"
" : '(no link)'"))
chk("no zero is rendered as a headline number anywhere in the strip",
page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
print("\n5. a failed request is an error, not a zero and not a memory")
visit("/index.html?project=projA")
before = json.loads(page.eval(CELLS_JS))
chk("the strip has real numbers to lose", len(before) == 4)
page.eval("""(() => {
const real = window.fetch;
window.fetch = function (u, o) {
if (String(u).indexOf('/api/wps/metrics') >= 0) return Promise.reject(new Error('probe offline'));
return real.call(this, u, o);
};
renderPipeline(ProjectData.getActive());
return true;
})()""")
for _ in range(20):
if page.eval("!!document.querySelector('#pipeline-strip .pipe-error')"):
break
time.sleep(0.3)
settle(0.6)
err = page.eval("(document.querySelector('#pipeline-strip .pipe-error')||{}).textContent||''")
chk("an explicit error is shown", bool(err.strip()), repr(err))
chk("...naming the failure", "probe offline" in err, repr(err.strip()[:90]))
chk("...with no cells left showing the numbers from before", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 0,
page.eval("document.querySelectorAll('#pipeline-strip .pipe-cell').length"))
chk("...and no zeros in their place", page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
visit("/index.html?project=projA")
chk("%s: four cells still render" % label, page.eval(
"document.querySelectorAll('#pipeline-strip .pipe-cell').length") == 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: every cell is at least a 44px tap target" % label,
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
".every(a => a.getBoundingClientRect().height >= 44)"),
page.eval("[...document.querySelectorAll('#pipeline-strip .pipe-cell')]"
".map(a => Math.round(a.getBoundingClientRect().height))"))
page.viewport(1400, 1000)
print("\n no localStorage read sits behind any of these numbers")
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"html", "index.html"), encoding="utf-8").read()
start = src.find("function renderPipeline")
body = src[start:src.find("\n }", start)] if start >= 0 else ""
chk("renderPipeline touches no localStorage", start >= 0 and "localStorage" not in body,
body[:200])
chk("...and reads its numbers from /api/wps/metrics", "/api/wps/metrics" in body)
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-pipeline-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed_two_projects(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("\nLauncher pipeline strip — B4 surface\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
run(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)
total = len(_PASS) + len(_FAIL)
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
if _FAIL:
for f in _FAIL:
print(" - " + f)
return 1
print("\nResult: " + _c("ALL PASS — four server counts, four shareable links.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())