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
241 lines
11 KiB
Python
241 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Is the app's state addressable? — S3 / T4.2.
|
|
|
|
X1 is a blocking dependency: CR-011 and CR-014 both promise an email carrying a
|
|
direct link to a work package, and before this there was no pushState anywhere in
|
|
the suite, so no work package had an address. This checks the promise those emails
|
|
will rest on.
|
|
|
|
1. a URL identifying a work package opens that work package
|
|
2. the same URL works for a SIGNED-OUT user, via login, landing on the target
|
|
— SKIPPED as of T10.4: local login is gone (D15/D16), and the redirect-
|
|
through-Okta replacement doesn't exist until T10.5 rebuilds login.html.
|
|
Re-test this once that lands. Signing in via a minted token stands in as
|
|
setup only, so scenarios 3-6 below still get a signed-in page to run on.
|
|
3. refresh preserves project, package, tab and view
|
|
4. Back and Forward move through states without a reload or a broken view
|
|
5. the URL survives being copied to a second browsing context
|
|
6. pushState is actually used; the count is recorded
|
|
|
|
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
|
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
|
|
|
|
|
|
def settle(page, seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
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-urlstate-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(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("\nAddressable state — S3 / T4.2\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
print("\n0. the module is present and does not need a hash")
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
settle(page)
|
|
chk("WPUrl is loaded", page.eval("typeof WPUrl") == "object")
|
|
chk("it merges rather than clobbers",
|
|
page.eval("WPUrl.href({wp:'wpA1'})").find("project=projA") != -1
|
|
and page.eval("WPUrl.href({wp:'wpA1'})").find("wp=wpA1") != -1,
|
|
page.eval("WPUrl.href({wp:'wpA1'})"))
|
|
chk("clearing a key does not drop the others",
|
|
"project=projA" in page.eval("WPUrl.href({wp:''})")
|
|
and "wp=" not in page.eval("WPUrl.href({wp:''})"),
|
|
page.eval("WPUrl.href({wp:''})"))
|
|
chk("it produces an absolute link for emails (X1)",
|
|
page.eval("WPUrl.absolute({wp:'wpA1'})").startswith("http"),
|
|
page.eval("WPUrl.absolute({wp:'wpA1'})"))
|
|
|
|
print("\n1. a URL identifying a work package opens it")
|
|
page.goto(base + "/wp-creation-index.html?project=projA&wp=wpA1")
|
|
for _ in range(30):
|
|
if page.eval("!!window.wpCreatorReady"):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(page, 1.0)
|
|
subject = page.eval("(document.getElementById('wp_subject')||{}).value||''")
|
|
chk("the deep-linked package is loaded into the form",
|
|
"horn/strobe" in subject, "subject field read %r" % subject)
|
|
|
|
print("\n6. pushState is used")
|
|
before = page.eval("history.length")
|
|
page.eval("typeof showDashboard==='function' && showDashboard()")
|
|
settle(page, 1.0)
|
|
after = page.eval("history.length")
|
|
chk("opening the dashboard adds a history entry", after > before,
|
|
"history.length %s -> %s" % (before, after))
|
|
chk("...and says so in the URL",
|
|
"view=dashboard" in page.eval("location.search"),
|
|
page.eval("location.search"))
|
|
|
|
print("\n4. Back and Forward move through states")
|
|
page.eval("history.back()")
|
|
settle(page, 1.2)
|
|
chk("Back leaves the dashboard",
|
|
"view=dashboard" not in page.eval("location.search"),
|
|
page.eval("location.search"))
|
|
chk("...and returns to the package, not to a blank page",
|
|
page.eval("location.search").find("wp=wpA1") != -1,
|
|
page.eval("location.search"))
|
|
chk("...without a full reload (the app is still initialised)",
|
|
page.eval("!!window.wpCreatorReady"))
|
|
page.eval("history.forward()")
|
|
settle(page, 1.2)
|
|
chk("Forward returns to the dashboard",
|
|
"view=dashboard" in page.eval("location.search"),
|
|
page.eval("location.search"))
|
|
chk("...and the dashboard is actually rendered, not just the URL",
|
|
page.eval("(document.getElementById('dashboard-view')||{}).style.display") != "none")
|
|
|
|
print("\n3. refresh preserves the state")
|
|
page.goto(base + "/wp-creation-index.html?project=projA&wp=wpA2")
|
|
for _ in range(30):
|
|
if page.eval("!!window.wpCreatorReady"):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(page, 1.0)
|
|
page.eval("location.reload()")
|
|
for _ in range(30):
|
|
if page.eval("!!window.wpCreatorReady"):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(page, 1.0)
|
|
subject = page.eval("(document.getElementById('wp_subject')||{}).value||''")
|
|
chk("a refresh lands on the same package", "wire pull" in subject,
|
|
"subject read %r" % subject)
|
|
|
|
print("\n3b. the SOP wizard's tab and step are addressable")
|
|
page.goto(base + "/work-package-suite.html?project=projA&tab=sop&step=3")
|
|
settle(page, 1.6)
|
|
chk("the wizard restores the deep-linked step",
|
|
page.eval("typeof currentStep!=='undefined' && currentStep") == 3,
|
|
page.eval("typeof currentStep!=='undefined' && currentStep"))
|
|
hlen = page.eval("history.length")
|
|
page.eval("typeof goToStep==='function' && goToStep(5)")
|
|
settle(page, 0.8)
|
|
chk("moving a step records it", "step=5" in page.eval("location.search"),
|
|
page.eval("location.search"))
|
|
chk("...as a history entry", page.eval("history.length") > hlen)
|
|
page.eval("history.back()")
|
|
settle(page, 1.0)
|
|
chk("Back returns to the previous step",
|
|
page.eval("typeof currentStep!=='undefined' && currentStep") == 3,
|
|
page.eval("location.search"))
|
|
|
|
print("\n5. the URL reaches the same view in a second context")
|
|
deep = base + "/wp-creation-index.html?project=projA&wp=wpA1"
|
|
page2 = browser.page()
|
|
try:
|
|
page2.clear_cookies()
|
|
page2.set_cookie("wp_session", tok["pat"])
|
|
page2.goto(deep)
|
|
for _ in range(30):
|
|
if page2.eval("!!window.wpCreatorReady"):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(page2, 1.0)
|
|
s2 = page2.eval("(document.getElementById('wp_subject')||{}).value||''")
|
|
chk("a different user opening the same URL sees the same package",
|
|
"horn/strobe" in s2, "subject read %r" % s2)
|
|
finally:
|
|
page2.close()
|
|
|
|
print("\n2. the same URL works for a signed-out user, via login")
|
|
print(" SKIPPED: local login is gone (D15/D16); the Okta-redirect replacement")
|
|
print(" doesn't exist until T10.5. Re-test the next= round trip once it lands.")
|
|
page.clear_cookies()
|
|
page.goto(deep)
|
|
settle(page, 1.6)
|
|
chk("a signed-out visitor is sent to login", "login.html" in page.eval("location.href"),
|
|
page.eval("location.href"))
|
|
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
|
|
chk("...carrying the requested target, package id and all",
|
|
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
|
|
# Setup only for scenarios 3-6 below, NOT a re-test of "signing in continues
|
|
# to the requested page" — 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 gets `page` to the same signed-in,
|
|
# on-target state those later scenarios need, without claiming to have
|
|
# exercised the (currently nonexistent) sign-in flow itself.
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(deep)
|
|
for _ in range(30):
|
|
if page.eval("!!window.wpCreatorReady"):
|
|
break
|
|
time.sleep(0.3)
|
|
settle(page, 1.0)
|
|
s3 = page.eval("(document.getElementById('wp_subject')||{}).value||''")
|
|
chk("...and lands on the work package itself, not a dashboard",
|
|
"horn/strobe" in s3, "subject read %r" % s3)
|
|
|
|
print("\n7. nothing secret rides in the URL")
|
|
qs = page.eval("location.search").lower()
|
|
leaked = [w for w in ("token", "session", "password", "secret", "auth") if w in qs]
|
|
chk("no credential-shaped parameter", not leaked, "found %s in %r" % (leaked, qs))
|
|
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 — the app's state has an address.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|