Files
Project-SDE-WP-Suite/tests/url_state_check.py
Matt Mabrey 290c9b078c T10.7 - a fake-OIDC-provider test seam, mirroring ldap_fake.py
Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().

The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.

tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.

tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).

tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.

One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).

wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:11:24 -07:00

271 lines
12 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 the real Okta sign-in round
trip (T10.7's fake provider stands in for Okta itself — see
server/okta_fake.py — but the app's own login.html, the redirect to
/api/auth/okta/login, the state round trip through SessionMiddleware, and
okta_callback()'s handling of ?next= are all real, unmodified code).
Landing on the requested target, not the home page, doubles as setup for
scenarios 3-6 below.
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 the real Okta round trip")
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)
# Drive the actual button, not a shortcut to it — its href already
# carries ?next= (login.js's safeNext()); this is the same click a
# person makes.
signin_href = page.eval(
"(document.getElementById('okta-signin')||{}).getAttribute('href')||''")
chk("the sign-in link itself carries ?next=", "next=" in signin_href, signin_href)
page.goto(base + signin_href)
for _ in range(30):
if "_fake_provider" in page.eval("location.href"):
break
time.sleep(0.3)
chk("the app hands off to the (fake) Okta provider",
"_fake_provider" in page.eval("location.href"), page.eval("location.href"))
# The fake provider's own picker page — a real page, not a shortcut.
# See server/okta_fake.py: only the network-touching Authlib calls are
# faked, not app.py's own login/callback/JIT/guard code.
identity_href = page.eval(
"(document.getElementById('okta-fake-identity-root')||{}).getAttribute('href')||''")
chk("the fake provider offers the seeded 'root' identity", bool(identity_href),
page.eval("document.body.innerHTML"))
page.goto(base + identity_href)
for _ in range(30):
href = page.eval("location.href")
if "login.html" not in href and "_fake_provider" not in href:
break
time.sleep(0.3)
settle(page, 1.0)
# NOT `"wp-creation-index.html" in location.href` alone — that string
# is in the ?next= parameter too, so this would pass while still
# sitting on login.html with the sign-in rejected (the same mistake
# D13/T10.7's LDAP predecessor caught and fixed here). Assert we
# actually LEFT the login page.
href = page.eval("location.href")
chk("signing in continues to the requested page, not the home page",
"login.html" not in href and "wp-creation-index.html" in href
and "wp=wpA1" in href, href)
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())