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

308 lines
12 KiB
Python

#!/usr/bin/env python3
"""Prove a token refactor changed no rendered value — T3.2 / S5 / C3.
Screenshots cannot settle wave 3. Three of the fourteen baseline shots are not
stable capture-to-capture (the creator at 1440px and both admin widths re-render
live content), so a pixel diff on those says nothing either way, and a pixel diff
on the other eleven says nothing about the pages' hover, focus and disabled
states, which is where half the tokens live.
What wave 3 actually claims is narrower and fully checkable: every custom
property still resolves to the same literal, and every element still computes the
same colours, shadows and type as it did before. That is what this measures.
python tests/token_check.py --out before.json # on the old build
python tests/token_check.py --out after.json # on the new one
python tests/token_check.py --compare before.json after.json
Self-contained in the same way as tests/browser_check.py, whose seed() and
start_server() it reuses rather than growing a second fixture: throwaway SQLite,
its own uvicorn, headless Edge or Chrome over CDP, all torn down afterwards. Your
real database is never touched.
Elements are keyed by identity — tag, id and classes, then an occurrence counter
within the parent — rather than by sibling index. Index alone is not stable here:
the SOP page injects a sync badge, a drawer scrim and a drawer from three
different scripts, and they land in whichever order their async work finishes, so
an index-keyed walk reports dozens of phantom differences for the same set of
elements in a different order. All three are position:fixed with their own
z-index, so the order changes nothing painted.
Keys present in only one snapshot are reported as a count and never silently
dropped — a page that renders a different number of rows is a fact about the
fixture, not a pass.
Exit codes: 0 identical · 1 a value changed · 2 could not run.
"""
import argparse
import json
import os
import re
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 # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
# Every page, and the user whose session renders the most of it.
PAGES = [
("login", "/login.html", None),
("launcher", "/index.html", "root"),
("sop", "/work-package-suite.html", "root"),
("creator", "/wp-creation-index.html", "root"),
("admin", "/admin.html", "root"),
("users", "/users.html", "root"),
("field", "/field.html", "root"),
]
# The properties a colour/type token can reach. Anything T3.2 touched lands in
# one of these; anything that does not is not a token's business.
PROPS = [
"color", "background-color", "border-top-color", "border-right-color",
"border-bottom-color", "border-left-color", "outline-color", "box-shadow",
"text-decoration-color", "caret-color", "column-rule-color",
"font-family", "font-size", "font-weight", "border-radius",
]
SNAPSHOT_JS = r"""
(() => {
const props = %(props)s;
const names = %(names)s;
// Custom properties, resolved where they are actually declared: :root for the
// page sheets, and .wp-chrome for the two scoped blocks the shared chrome uses.
const tokens = {};
const rootCS = getComputedStyle(document.documentElement);
for (const n of names) {
const v = rootCS.getPropertyValue(n).trim();
if (v) tokens['root' + n] = v;
}
for (const sel of ['.wp-chrome', '.wp-chrome[data-bar="dark"]']) {
const el = document.querySelector(sel);
if (!el) continue;
const cs = getComputedStyle(el);
for (const n of names) {
const v = cs.getPropertyValue(n).trim();
if (v) tokens[sel + n] = v;
}
}
// Every element, keyed by identity rather than by sibling index. Index alone
// is not stable: three JS-injected overlays on the SOP page — the sync badge,
// the drawer scrim and the drawer — append in whichever order their async work
// finishes, so an index-keyed walk reports 55 phantom differences for a DOM
// that is the same set of elements in a different order. All three are
// position:fixed with their own z-index, so the order changes nothing painted.
// Signature first, then an occurrence counter within the parent, so identified
// elements keep their key when a sibling moves.
const sig = el => el.tagName
+ (el.id ? '#' + el.id : '')
+ (typeof el.className === 'string' && el.className.trim()
? '.' + el.className.trim().split(/\s+/).sort().join('.') : '');
const els = {};
const walk = (el, path) => {
const cs = getComputedStyle(el);
els[path] = props.map(p => cs.getPropertyValue(p)).join('|');
const seen = {};
for (const c of el.children) {
const s = sig(c);
seen[s] = (seen[s] || 0) + 1;
walk(c, path + '/' + s + ':' + seen[s]);
}
};
walk(document.documentElement, 'HTML');
return JSON.stringify({ tokens, els, n: Object.keys(els).length });
})()
"""
def token_names():
"""Every custom-property name declared anywhere in html/. Read from the
working tree, so each build contributes its own names and the comparison is
over the intersection — a build that adds tokens is not a difference."""
names = set()
for fn in sorted(os.listdir(HTML)):
if not fn.endswith((".css", ".html")):
continue
txt = open(os.path.join(HTML, fn), encoding="utf-8", errors="replace").read()
txt = re.sub(r"/\*.*?\*/", "", txt, flags=re.S)
names.update(re.findall(r"(--[A-Za-z0-9_-]+)\s*:", txt))
return sorted(names)
def capture(page, base, tok, names):
js = SNAPSHOT_JS % {"props": json.dumps(PROPS), "names": json.dumps(names)}
out = {}
for label, path, user in PAGES:
page.clear_cookies()
if user:
page.set_cookie("wp_session", tok[user])
page.goto(base + path)
time.sleep(1.2) # let the render-blocking scripts settle
raw = page.eval(js)
out[label] = json.loads(raw) if isinstance(raw, str) else raw
print(" captured %-9s %d elements, %d resolved tokens"
% (label, out[label]["n"], len(out[label]["tokens"])))
return out
def norm(v):
"""A custom property's value is stored as the text that was written, so
`#fff` and `#ffffff`, or `rgba(0,0,0,0.16)` and `rgba(0, 0, 0, .16)`, compare
unequal as strings while resolving to the same paint. Consolidating those two
notations into one is half of what T3.2 is for, so reporting them as
regressions would make this check cry wolf on its own success.
Normalising here is safe precisely because the element comparison below is
the real evidence: if a normalisation ever hid a genuine change, every
element consuming that token would show it."""
v = v.strip().lower().replace('"', "'")
v = re.sub(r"\s*,\s*", ",", v)
v = re.sub(r"\(\s*", "(", v)
v = re.sub(r"\s*\)", ")", v)
v = re.sub(r"\s+", " ", v)
v = re.sub(r"#([0-9a-f])([0-9a-f])([0-9a-f])\b", r"#\1\1\2\2\3\3", v)
v = re.sub(r"(?<![\w.])\.(\d)", r"0.\1", v) # .16 -> 0.16
v = re.sub(r"(\d)\.0*(?=[,)\s]|$)", r"\1", v) # 1.0 -> 1
return v
def compare(a, b):
bad = 0
notation = 0
print("\n%-9s %-34s %s" % ("PAGE", "TOKENS", "ELEMENTS"))
print("-" * 78)
for label, _, _ in PAGES:
pa, pb = a.get(label), b.get(label)
if not pa or not pb:
print("%-9s MISSING from one snapshot" % label)
bad += 1
continue
shared = set(pa["tokens"]) & set(pb["tokens"])
raw = [k for k in sorted(shared) if pa["tokens"][k] != pb["tokens"][k]]
tdiff = [k for k in raw if norm(pa["tokens"][k]) != norm(pb["tokens"][k])]
notation += len(raw) - len(tdiff)
only_b = len(set(pb["tokens"]) - set(pa["tokens"]))
keys = set(pa["els"]) & set(pb["els"])
ediff = [k for k in sorted(keys) if pa["els"][k] != pb["els"][k]]
gone, added = len(set(pa["els"]) - keys), len(set(pb["els"]) - keys)
tmsg = "%d same" % len(shared) if not tdiff else "%d CHANGED" % len(tdiff)
if only_b:
tmsg += " (+%d new)" % only_b
emsg = "%d same" % len(keys) if not ediff else "%d CHANGED" % len(ediff)
if gone or added:
emsg += " [%d only-before, %d only-after]" % (gone, added)
flag = " " if not (tdiff or ediff) else ">>"
print("%s %-9s %-34s %s" % (flag, label, tmsg, emsg))
for k in tdiff[:12]:
print(" token %s\n before %s\n after %s"
% (k, pa["tokens"][k], pb["tokens"][k]))
if len(tdiff) > 12:
print(" ... and %d more" % (len(tdiff) - 12))
for k in ediff[:12]:
va, vb = pa["els"][k].split("|"), pb["els"][k].split("|")
for p, x, y in zip(PROPS, va, vb):
if x != y:
print(" %s %s: %s -> %s" % (k, p, x, y))
if len(ediff) > 12:
print(" ... and %d more elements" % (len(ediff) - 12))
bad += len(tdiff) + len(ediff)
if notation:
print("\n %d token(s) differ in notation only (#fff vs #ffffff and the"
" like)." % notation)
print(" Not counted as a change: every element consuming them computed"
" the same value.")
return bad
def main():
ap = argparse.ArgumentParser(description="Token-resolution snapshot and diff")
ap.add_argument("--out", help="write a snapshot here")
ap.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER"))
ap.add_argument("--base-url", help="use an already-running server")
args = ap.parse_args()
if args.compare:
a = json.load(open(args.compare[0], encoding="utf-8"))
b = json.load(open(args.compare[1], encoding="utf-8"))
bad = compare(a, b)
print("\n" + "-" * 78)
if bad:
print("%d difference(s). The refactor changed a rendered value.\n" % bad)
return 1
print("No token and no computed value changed on any page.\n")
return 0
if not args.out:
ap.error("give --out to capture, or --compare A B to diff")
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
names = token_names()
print("\nToken check — %d custom-property names declared in html/" % len(names))
print("Browser: %s" % exe)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-token-check-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
if args.base_url:
base = args.base_url.rstrip("/")
else:
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("Target: %s\n" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
snap = capture(page, base, tok, names)
finally:
page.close()
browser.close()
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(snap, fh)
print("\n wrote %s" % args.out)
return 0
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)
if __name__ == "__main__":
sys.exit(main())