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
383 lines
18 KiB
Python
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())
|