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