What each test covers
Notes on the individual test files in /tests/. The hazards that apply to writing any of them — Web Crypto’s secure context, --headless=new, waiting on content, the @noble pin — are in tests/CLAUDE.md, which is loaded whenever you open this directory; this file is the lookup.
Where an entry below says a job “skips” for a reason, read it against the rule
in tests/CLAUDE.md — A test that does not run is not a passing test — which
changed what half of those sentences mean on 2026-09-02. A prerequisite that
is genuinely ABSENT (an unset variable, MIT Kerberos not installed, a layout
that is the tests image rather than a checkout) is still a skip, and is now a
RECORDED one that reaches <skipped> in report.xml rather than exiting 0 as a
pass. A prerequisite that is PRESENT and cannot do what the job needs — a mock
that answered and does not publish an attribute, an endpoint that 404s, a stack
the launcher already decided was up — is a FAILURE now. That second half is
there because six probes reading a moved URL turned fifty-four jobs off on
2026-09-01 under a report that said failures="0". tests/expectation.js is
the mechanism.
This is 139 of the 157 test files, not the suite. The entries below are the ones with something non-obvious to record; the rest are named at the foot of this file with no note, and their absence means nothing about their importance. To recheck the split:
ls tests/*.js | xargs -n1 basename | sort > /tmp/actual
grep -oE '^- .[a-z0-9_]+\.js.' docs/test-suite-map.md | sed 's/^- .//; s/.$//' | sort > /tmp/listed
comm -23 /tmp/actual /tmp/listed # test files with no entry, plus the five helpers
Several tests are named for what they check rather than for the module they check, and that is not stylistic: tests/Dockerfile copies test scripts and the modules they exercise FLAT into one directory, so a test sharing a basename with a module silently overwrites it or is overwritten by it. jwk_pem.js is checked by jwk_pem_encoding.js, krb5_crypto.js by krb5_crypto_vectors.js, krb5_pac.js by krb5_pac_layout.js, dpop.js by dpop_vectors.js, jose_jwe.js by jose_jwe_encryption.js, url_safety.js by url_safety_schemes.js. The module keeps its own name because other code imports it. testsImageHasNoCollidingFilenames() in jwk_pem_encoding.js now fails the suite if a new pair ever collides — three tests had been reporting green in ~30ms while executing a module.
Individual test files in /tests/:
oauth2_authorization_code.jsoauth2_client_credentials.jstoken_http_exchange.js(the HTTP tab on the token exchange pane —oauth2_oidc_2.html’s second tab, showing the Token Request and its response as they actually went: method, URL, headers and body each way, and how long the far end took. It exists because the interesting half of that exchange cannot be observed by the page at all: this workflow’s default is to have the api make the call, so the request that matters is made by another process and the browser only sees it because the api hands back what it saw underhttp_exchange(buildHttpTrace()inapi/server.js, switched on per call byhttp_trace: true). Every link in that chain fails SILENTLY — the pane keeps rendering and simply shows the browser’s own call to the api instead, a plausible-looking HTTP exchange with the wrong URL in it — so the URL shown is asserted to be the TOKEN ENDPOINT, compared against the page’s own#token_endpointfield rather than a literal, and the note is asserted to name the end that made the call. Which end that is comes off the page, not from an assumption: a static deployment has no api,backendAvailable: falsedisables the back-end radio and the call is made from the browser, where the trace is the jqXHR and the pane says which response headers CORS withheld. It runs the Client Credentials grant because the pane, the handler and the trace are the same for every grant this page sends and that one needs no login — the code grant’s own path to this pane is covered byoauth2_authorization_code.jsandoidc_authorization_code.js. Three further things it pins. The tab is asserted BEFORE the call as well as after (an empty panel behind a tab is indistinguishable from a tab that does not work, and a test that only ever looks after a successful exchange cannot tell the difference either). The tab is never selected by code and its LABEL carries the status — a successful call collapses this whole pane, so the test re-opens it the way a reader does, by clicking the title. And LAYOUT is measured with the exchange in it: nothing the panel draws may cross the pane’s border and neither the panel nor the page may scroll sideways, which an empty pane would satisfy trivially. Mutation-tested: making the api never attach the trace fails it by name, at the note, with the api’s own URL in the message. It covers FOUR panes that carry this exchange. The request form has the tab it was composed on; a successful call COLLAPSES that form, so the pane left on screen is Token Endpoint Results, which carries the same view under a Tokens / HTTP strip of its own — and that pane is not in the page at all, it is built as a string in four branches ofoauth2_oidc_2.jsand given its tab afterwards byattachHttpTabToTokenResults(). Four things are pinned there that the first pane cannot pin: that the strip is attached at all (dropping the call fails it by name), that there is exactly ONE strip after a rebuild, that the access token survived being re-parented into a tab panel — the tokens are filled in by value after the pane is built, so a wrap that moved them out of#token_endpoint_resultwould cost the reader the thing the pane is for — and that the two panels’ text is character-for-character identical, which is what stops a second implementation of this view from growing beside the first. It then walks the journey that reaches the other branch:token_detail.htmland back via?redirectFromTokenDetail=true, whererecreateTokenDisplay()rebuilds the pane out of localStorage — the tokens, but not an exchange, since the exchange is kept per GENERATION and that pane cannot know which of the stored ones the current-token slots belong to. The tab has to say so and point at Token History, rather than being empty (a broken-looking tab) or claiming nothing has been sent yet (a lie about a page covered in tokens). Both wordings are mutation-tested. The third pane is Currently Viewing, and it is the one this file grew for: activating a generation in Token History draws that generation’s kept exchange through the same renderer, off aviewingchannel read back out oftoken_history— the one path here that can break without either live pane noticing, since it is fed by a JSON document rather than by the ajax handler that just ran. It is asserted on CONTENT (method, URL, status, response body, the “kept with it at” line) rather than on the tab existing, because a pane drawing its empty sentence looks exactly like one that was never given anything. The fourth is the refresh pane’s, in the only state Client Credentials can produce — no refresh token, so no Refresh Request, so the empty state, which is the one that must say so rather than be a blank panel behind a tab;oidc_authorization_code.jsmakes a real one. And what this file REVERSED is worth knowing before editing it: it used to assert that nothing about the exchange reached localStorage. The exchange is now kept, so that became an assertion about REDACTION — the stored copy carries the whole exchange with the credential-bearing header and parameter values replaced, the live panes are checked to be UNredacted (the two copies are deliberately different, and a redaction leaking into the live view would take away the bytes the tab exists to show), and the client secret is looked for in the RAW stored document rather than field by field, because a secret copied into a field nobody thought to check is exactly the failure that is for. Needs the client, the api and an identity provider)oauth2_implicit.jsoidc_authorization_code.js(the OIDC Authorization Code flow end to end, including the token detail pages, two Refresh Requests and RP-initiated logout. It also carries the refresh HTTP tab, inverifyRefreshHttpTab(), because it is the only test here that makes a real Refresh Request:token_http_exchange.jsdrives Client Credentials, which returns no refresh token, so the empty state is the only refresh state that file can reach. Asserted on the FIRST of the two refreshes, before the token detail pages navigate away and rebuild the panes: both refresh panes carry the exchange, their text is character-for-character identical (onerenderHttpExchange()call, two hosts), the results pane carries exactly ONE tab strip after its rebuild, and the refreshed generation intoken_historycarries its own redacted copy with no client secret and no HTTP Basic header in it)rfc9700_client.js(RFC 9700, the client half, with no browser and no services — so it never skips. Four kinds of check. The CATALOGUE inclient/src/rfc9700.js— fifteen sections all represented, ids unique and matching the section they are filed under, every row carrying a level, a title and a note. The MODE-OFF CONTRACT, which is the reason this file is not merely a unit test: with the compliance checkbox clear the workflow must behave exactly as it did before any of it existed, and that is invisible from inside every other RFC 9700 test because they all turn the mode ON. It is asserted lexically — anrfc9700.enabled()must appear inside the enclosing function of every call into a check, over both page modules — and the boundary is the enclosingfunctionrather than a line count, because a line window silently changes meaning as a function grows. It is also whyrfc9700GateAuthorizationRequest()carries a guard although both of its callers already ask: a guard living only at the caller satisfies the contract and not the check, and the two disagreeing is how the contract stops being asserted at all. THE RULES, each of the four check functions driven directly with inputs this file chooses and the exact requirement id asserted — a finding that fires under the wrong id sends the reader to the wrong section of the specification. That is where the negatives live, and they are most of the value: almost every rule in RFC 9700 fires only against a misbehaving server, and this is the only place one can be produced on demand. And the ALWAYS-ON posture —/callbacknot being an open redirector, 303 rather than 307, the three headers, no iframe anywhere, nopostMessagein this workflow, no'*'target origin, the one message listener in the tree matchingevent.origin, no token in a query parameter, no third-party resource on either page — asserted over the source that holds it, because those are the properties a well-meaning edit removes silently. Note the open-redirector check reads a STATEMENT rather than a line and requires theLocationto BEGIN withdebuggerLandingUrl():/callbackdeliberately copies the response’s parameters through, so “nothing off the request appears in the header” would be both wrong and unimplementable — what must never happen is request-derived text deciding the origin or the path)rfc9700_flows.js(the OAuth2/OIDC matrix a SECOND time, with the debugger AND the mock STS both in RFC 9700 mode. The twelveoidc_flows.jsjobs run both sides permissive, which is what almost every provider this tool is pointed at actually is; these run the other pairing, and neither substitutes for the other — permissive asks whether the debugger still works against a server implementing none of this, compliant asks whether it meets one that does. Five jobs from one script.RFC9700_FLOW=refusedis the important one and the reason this is a matrix: a compliance mode that issues a token on the happy path looks finished and can be worth nothing, so that job covers the six disabled grants and the distinct reason on each, a grant RE-ENABLED through the DOM and forced past the selector (still refused at the request, becausedisabledis a property of a control and the rule is a property of the request — and the first draft of that check passed for the wrong reason, since assigning a disabled option to a select leaves the value empty), a request with no metadata behind it, the mode-off contract seen from the browser, and that turning the mode off gives every control back. The other four run a grant end to end and then present the same authorization code a SECOND time, which must be refused citing 3.1 — the one negative that cannot be reached from the happy path. Every job starts with the always-on posture over plain HTTP before a browser is built, and every job REFUSES BY NAME to run against a permissive STS: pointed at one it would exercise the client’s checks against a server that never disagrees with them, which is the single arrangement that proves nothing. One thing the pairing needs that the permissive pass does not: the debugger’s callback registered with the STS throughPOST /realm/rfc9700/admin-api/config/set— not a workaround, since an RFC 9700 server comparesredirect_uriby exact string match against URIs it was given, so registering it is the step the specification requires. Gated onRFC9700_STS_URL, which since 2026-08-25 names a TRUST REALM on the same mock the permissive jobs use rather than a second instance:oauth2.rfc9700is restart-only over there because it derivesglobal.https, and iya-sts marks itrealmRuntimeso a REALM may carry it — a realm binds no socket, so only the mode’s checks change.configureStsRfc9700Realm()incommon/common.shcreates it and is the capability probe that skips these five jobs when the submodule is too old. What a realm cannot bring is a SCHEME, so the mock is started withSTS_HTTPS=trueand the whole suite talks https to it; the certificate it regenerates every start is fetched once and installed as a truststore anchor for node, for Chrome (an exact SPKI pin) and for the api, rather than by turning verification off anywhere. Seedocs/rfc9700.md)oidc_dpop_optional.js(DPoP is optional on the OAuth2 / OIDC workflow: off by default, on when the pane asks, off again discards the key — and, the phase it exists for, the SD-JWT VC workflow’s own DPoP switch does not decide for this one. The thumbprint is recomputed in node from the public JWK in storage (RFC 7638 section 3.1) rather than read from the pane, so “the token is bound to this key” is checked against the arithmetic and not against the page agreeing with itself. Needs the STS mock and the client — no identity provider, no api. Mutation-tested twice: reverting the whole fix fails it at the on phase, and reintroducing only the cross-over — this workflow’s state consulted first, the VC workflow’s used as a fallback — fails it at the phase written for that, which is what stops the last phase being decoration)oidc_userinfo.js(the UserInfo endpoint through all three ofoauth2_oidc_2.html’s UserInfo Data links — the token set the Authorization Code flow produced, the one the refresh call produced, and the one Activated from Token History. Click the link, press Retrieve UserInfo without touching the configuration, check the answer, come back with Return to debugger, next link. Three links, because they differ only in which access token they carry (?type=token_access_token,?type=refresh_access_token,?type=history_access&generation=N) and every token in the run belongs to the same user — so a link carrying the wrong one still returns a correct-looking answer. The test therefore reads the access token the UserInfo page loaded and requires it to be the one from the pane the link was in, and activates generation 1 for the history case so that the token asked for is demonstrably not the live one. It also requiressubto match the ID token in the same pane (OIDC Core 5.3.2). Mutation-tested: pointing the refresh link at the original token fails it. Runs against both OPs, and unlike the flow matrix it exercises the page’s DEFAULT initiation, which on a build that HAS the api is the api — so these two jobs need the api service too. Note that default is worth knowing: the markup gives the front-end radiochecked="true"and the back-end onechecked="false", butchecked="false"is still the attribute being PRESENT, so both are checked in the source and the last of two radios sharing a name wins — the front-end intent is dead, and the effective default matches every other initiation pane. On the deployed static sites there is no api (backendAvailable: false), whereuserinfo.jsdisables the back-end radio and forces the front-end one; the test reads which build it is off the page rather than assuming, and still fails a backed build that stopped defaulting to the api or a backend-less one that left the disabled option selected. Pinning the api default unconditionally is what failed the two live-site jobs on 2026-08-07, for a reason that had nothing to do with UserInfo)oauth2_auth_style.js(the Auth Style radios — POST or Header — on the token and refresh panes ofoauth2_oidc_2.html, issue #328. Two jobs, one per initiation (AUTH_STYLE_INITIATE=front|back), because the browser (clientBasicAuthHeader()) and the api (/token) build the Basic header in different processes. It registers a CONFIDENTIAL client declaringclient_secret_basic, so the mock refuses a request that does not present the secret by Basic (STS-OAUTH-0019) and a token coming back means something. It pins the two bugs that issue found: the browser path sent NO credential at all for the Header style, and a page load restored the token pane’s choice onto the REFRESH pane’s radios, so the next button press wrote POST over a Header choice — which broke the api path too. The second is caught by reloading the page under two MIXED stored choices and requiring each pane to come back with its own. It then asserts each pane’s request preview (the Basic line, noclient_secretin the body), redeems the code and refreshes, and reads the REQUEST off each pane’s HTTP tab:authorizationis exactlyBasic base64(client_id:client_secret), the body has noclient_secret, and the secret appears nowhere in the panel in the clear. On the api path that panel is the api’s own trace of its outbound call. Needs the client and the mock STS; the back-end job also needs the api and is not scheduled whenLDAP_AVAILABLE=false)oidc_flows.js(every OIDC authentication flow, with DPoP and without — six flows times two, twelve jobs from one script, throughoauth2_oidc_1.html/oauth2_oidc_2.htmlagainst the mock STS. The OP is the mock rather than Keycloak because it advertises and implements all seven response types and is in this project’s control, which also means the jobs need no identity provider. The check that has to come first is what the page is about to SEND: everything downstream still looks plausible whenresponse_typeis wrong, which is exactly the state five of the six were in — seedocs/oidc-flows.md. Then: the response arrives in the fragment and no token appears in the query; exactly the artifacts theresponse_typenamed are present and the others absent; the ID token verifies againstjwks_uriand carries the nonce the page sent;at_hash/c_hashmatch the access token and code that shipped with it (computed here from OIDC section 3.3.2.11, not read back from the server); and for the four code-bearing flows the code redeems at the token endpoint. The DPoP axis is not symmetric, which is why it is a matrix rather than a flag: withOIDC_DPOP=ona code-bearing flow must carrydpop_jkton the authorization request and come back withcnf.jktequal to the RFC 7638 thumbprint recomputed here from the stored public JWK, while an Implicit flow must bind nothing and the pane must say why — those two never reach a token endpoint, so they are exactly where a DPoP implementation can look busy and achieve nothing. An access token minted by the authorization endpoint is asserted to carry nocnfin every case. It exchanges the code browser-direct, which keeps the jobs free of the api service and its CORS allow-list. Mutation-tested: reintroducing theresponse_typeselector bug fails all of them; blanking the results pane or re-inverting theid_tokencheck each fails the job it belongs to; removing the Implicit-flow DPoP notice fails the two Implicitonjobs; and dropping the proof from the Token Request fails the code-bearingonjobs with a 400 from the token endpoint, because the mock enforces thedpop_jktcode binding — a code bound to a key cannot be redeemed without it)sd_jwt_vc_issuance.js(the OID4VCI / SD-JWT VC workflow; needs the STS mock and Keycloak. ItspresentationHandoff()section covers the Present It offer on steps 3 and 4 — that the click copies nothing, that the five readiness states stay distinct, and that step 4 names the generation in hand and disowns an unkept refresh. Mutation-tested: collapsing the two missing-key states, dropping the handler’s own guard so a re-enabled button navigates anyway, dropping the unkept-refresh or generation clause, copying the credential into a new key, and rendering the offer only on the populated-history path are each caught. Note its “the offer is in the last pane” check now means the last pane the WORKFLOW puts on the page: every page also ends with the shared VC Tools pane, which is excluded — and asserted to be there, so the exclusion cannot hide a regression instead of accommodating one. ItsclaimsSelection()section covers the Claims to Request pane on step 1 — the rows are built from what the issuer advertises (read off the wire, since that is/admin/vcconfiguration and it survives between tests), all checked by default, and unchecking two follows the selection into the authorization request’sauthorization_details, into the granted details echoed by the token response, into what step 2 asks approval for, and into the credential’s Disclosures.claimsCannotTravelOnAScope()covers the state the pane shipped broken in — a subset chosen on the DEFAULTscoperoute, which no request can carry — asserting that the pane says so itself rather than in the hand-off note, that the authorization request really carries nothing, and that the pane’s one-click switch toauthorization_detailsputs the same selection on the wire.preAuthorizedClaimsRequest()does the same over HTTP for the flow that has no authorization request — section 6.1.1’sauthorization_detailson the Token Request — plus the issuer’s three refusals: an unadvertised path, a claim described twice (Appendix A.3) and an empty claims path pointer. Mutation-tested from both ends: dropping the client’sclaimsmember and dropping the issuer’s filtering are each caught)sd_jwt_vc_presentation.js(the OID4VP / SD-JWT VC presentation workflow: the four pages against the mock Verifier, plus five negatives — a replayed presentation, a KB-JWT signed by the wrong key, an invented Disclosure, one removed after signing, and a claim the verifier asked for withheld. Needs only the STS mock. It pins the mock Verifier’s request before anything else —POST /admin/vc-verifier-configwith the two claims it selects, and theformatnamed on every/oid4vp/start— because both are runtime configuration on that mock and a stack somebody has driven by hand otherwise refuses every presentation here, the control case first)sd_jwt_vc_presentation_waltid.js(the presentation workflow against walt.id’sverifier-api2: walt.id issues the credential, walt.id verifies the presentation, and our pages are the wallet in between. Positive by value and by reference, plus two negatives — a withheld claim, which walt.id accepts because it runs no DCQL-fulfilment policy, so what is asserted is that the claim never reached it and that our step 3 reports the shortfall; and a replay of accepted bytes into a second session, which walt.id does refuse (nonce-check,NONCE_MISMATCH) and which is what makes itsSUCCESSFULon the positives mean anything. Skipped whenWALTID_VERIFIER_URLis unset)sd_jwt_vc_waltid.js(the same workflow against walt.id’sissuer-api2— the interoperability check; needs thewaltid-issuercontainer and Keycloak, and is skipped whenWALTID_ISSUER_URLis unset)wsfed_sso.js(driven againstwsfed_request.html— the page calledwsfed_tools.htmlbefore 2026-08-09 — the WS-Federation Passive Requestor round trip and sign-out:wa=wsignin1.0to the IdP, login, thewresultPOSTed to the api’s/wsfedlanding, the SAML token inspected onwsfed_response.html— thenwa=wsignout1.0from the same page, in the same browser, because sign-out needs the session sign-in established. Sign-out returns no token, so what is checked is that the browser came back towreplyas a sign-out and that the session is really gone: signing in again must show the IdP’s login form rather than silently minting a second token off a surviving SSO cookie. Thesignout=flag on that last hop is not asserted to bewsignout1.0— the extension’sfinishLogoutsends nowaat all, so the api’s fallback makes itsignout=1;wsignoutcleanup1.0is the value whenfrontchannelLogoutis involved instead. Every one of its 21 combinations runs TWICE — once per IdP — since 2026-08-15: against thekeycloak-wsfedcontainer (gated onWSFED_METADATA_URL) and against the mock STS, which grew this profile in its own repository (gated onWSFED_STS_METADATA_URL, which the launchers set wherever the STS is reachable BY THE BROWSER). The two are complementary rather than redundant: Keycloak is somebody else’s implementation and the only interoperability evidence here, while the mock actually READS the request — it refuses awauthnaming a method it cannot perform, a token type itsfed:TokenTypesOffereddoes not list, andwreqptroutright, so a request that is well-formed but wrong fails there and passes at Keycloak. The mock is also the only WS-Fed IdP a host or live-site run has, and the only one whose metadata the page can actually FETCH (see the CORS note below), so it is what exercises the metadata-parsing path at all. Four differences are markedIDP:in the test — the submit button’s id, where the passive endpoint sits relative to the metadata, which token types an inlinewreqmay ask for, and whether sign-out returns the browser by redirect (Keycloak) or by a LINK on a signed-out page (the mock, deliberately: a 302 would abandon the front-channel cleanup pings that page fires). Needs the api either way; each IdP’s jobs skip naming which one is absent../local-run-tests.sh --wsfed-onlyruns just this one, against both IdPs;--wsfed-only=stsor=keycloaknarrows it to one, and=stsis the fastest loop because it does not wait for WildFly. It also asserts the shared Operations History closed the sign-out out asSuccess, matching the label exported byclient/src/wsfed_history.jsrather than a string of its own — the point being that a dispatched call is only proved complete when the entry stops sayingSent) It drives either landing without knowing which — the assertions are on what the user sees, and both landings put the same thing on the same page — so it runs against the deployed static sites too. It is skipped only when there is nothing to receive the POST:remote-run-tests.shprobes the target with a real POST and setsWSFED_LANDING_AVAILABLE, and the skip message says to apply the infrastructure.edgeLandingContract()in that test additionally compares the Lambda’s hand-off key names againstwsfed_msg.js’s, since nothing else can catch that drift before a deployment. Related: the IdP metadata cannot be fetched cross-origin either — the cloudtrust extension’s@GET @Path("descriptor")returns a bare String and the extension contains noCors.add()anywhere, so no clientwebOriginssetting can make that endpoint sendAccess-Control-Allow-Origin; the page’s metadata load is best-effort for that reason and the test derives the sign-in endpoint instead.saml_encrypted_sso.js(SSO against Keycloak’ssaml.encrypt=trueclient, then decrypt the EncryptedAssertion in the browser. The decryption never needed a backend —decryptAssertion()is node-forge in the page — but the response must arrive: that client is provisionedsaml.force.post.binding: "true", so it POSTs regardless of the AuthnRequest, and the Redirect fallback the other SAML jobs use statically is not available to it. Gated onSAML_LANDING_AVAILABLE, whichremote-run-tests.shprobes; it asserts the ACS default is a landing and that the request asked forProtocolBinding=HTTP-POST, rather than discovering either through a timeout)keypair_storage_optout.js(the key-pair localStorage opt-out on all three protocols — the “Save this key pair in browser localStorage” checkbox onsaml_request.htmlandwstrust_tools.html, in both states. It earns a browser test because the failure mode is silent in the reassuring direction: if the guard insaveState()broke, the box would still untick and the warning note would still appear while the private key went on being written — only readinglocalStorageshows it. It asserts that checked still saves, that clearing purges what was already written rather than merely skipping future writes, that the purge survives later saves (the assertion that fails if the guard sits only in the change handler instead of insaveState()), that unrelated configuration and the STS’s ownwst_enc_certare untouched, that the preference survives a reload, and that the response page then reports the missing prefill instead of claiming one. Needs only the client — no IdP, no STS — so it is never skipped. Note the two pages differ in a way that bit the first draft: the SAML page ships with Digitally sign ON, the WS-Trust page ships with it OFF, so on WS-Trust the key-pair pane and this checkbox are hidden until signing is enabled. Mutation-tested: ignoring the checkbox, dropping the purge, purging the wrong field set, losing the preference across a reload, and leaving the response page’s prefill promise in place are each caught. It also covers the SD-JWT VC holder key pair, which is a bigger surface: that key is written on issuance step 2, read on step 4 to refresh, read by the presentation workflow to sign the Key Binding JWT, and kept per generation inside Credential History — so the opt-out purges all four copies, the gate lives insd_jwt_vc.js’sset()rather than at the call sites (three bundles write it), and the paste-in fields on step 4 and presentation step 2 are what keep the workflow usable at all. Mutation-tested there too: leaving the history copies, removing the gate, stripping the credential along with its key, never re-enabling Reuse the bound key, matching any pasted key instead of the bound one, and ignoring the pasted key entirely are each caught)-
jwk_pem_encoding.js(the JWK → SPKI PEM encoder,client/src/jwk_pem.js. The DER is checked by round-tripping it through node’s own SPKI parser — a stronger oracle than matching a second encoder’s bytes, and one that needs no extra package installed — plus the encoding rules whose failures are silent: the DER INTEGER sign byte, minimal encoding, both length forms, and an EC coordinate a publisher trimmed a leading zero from. It also fails if any file inclient/srctakes a require that would putellipticback into a bundle. No browser and no services, so it never skips. Mutation-tested: dropping the sign byte, the coordinate padding, the zero-stripping, the BIT STRING’s unused-bits byte, the long-form length, the curve OID, or appending a stray byte each makes it fail. Named_encodingrather thanjwk_pemon purpose, and for a reason that had already bitten: the tests image copies both this test andclient/src/jwk_pem.jsflat into one directory, the module’s COPY runs second, and it therefore REPLACED the test —node jwk_pem.jsloaded a module, printed nothing, exited 0, and the runner recorded a pass. The elliptic scan, the only check that catches a new page requiringjwk-to-pem, had not run in a containerized build until the rename on 2026-08-08. It also now carriesstsModuleClosureIsCopied(), which is about the tests IMAGE rather than about JWKs and lives here because the flat-copy guard already did:tests/Dockerfilecopies a hand-picked set ofsts/*.jsfor the four in-process mock-KDC jobs, and that set goes stale when thests/GITLINK moves rather than when any line in this tree changes — the mock gives a module the image already has a newrequire, and the job dies at load withCannot find module './x'naming a file the image HAS, while every host run stays green because a checkout has the whole submodule. It has happened four times:admin_stats.js,config.js,audit.js, and the RFC 9700 bump, which added./oauth2_bcptoapp.jsand./applicationsto three others. So it walks the closure instead of listing it — seed from the Dockerfile’s own COPY statements, follow each relative require, require the result to be a subset of what is copied — and it also catches the inverse, a COPY naming a file the submodule no longer has, which stops the image build instead of a test. Mutation-tested both ways Since 2026-08-22 it also compares the THREE places a bundle is named —BUNDLESinclient/build.js, theRUN browserifylines inclient/Dockerfile, and that file’sCOVERAGEloop — because the third fails SILENTLY: a bundle missing from it still builds, ships, works and passes its own tests, and reports no coverage. Seven were missing when the check was written (all six Kerberos bundles andpki), and the Dockerfile had carried a comment saying six of them were for months. It lives in the ORDINARY suite for the same reason the beacon check does: the plain launchers never execute that block, so nothing under./run-coverage.shcan be what notices. It also catches a--standalonename that disagrees between the two builds, which makes every inlineonclickon that page a ReferenceError under coverage and nowhere else.) bbs_crypto.js(BBS over BLS12-381 —client/src/bbs.js, shared by thebbs-2023cryptosuite and the Digital Signature page’s BBS pane. Two oracles, neither of them ours, and the split between them is the point.@digitalbazaar/bbs-signaturesis a different implementation (ESM-only, which is why the browser cannot use it and why there are two at all) and settles theBLS12-381-SHA-256suite by byte identity — BBS signing is deterministic, so two correct implementations do not merely accept each other’s signatures. The draft’s own published vectors, vendored astests/bbs_vectors.json, settle everything that one cannot: KeyGen with itskey_infoandkey_dst, and the wholeBLS12-381-SHAKE-256suite, which at 3.1.0 that implementation does not reproduce — its@noble/curves2.x hashes to G1 differently under XOF, while this module matches the draft byte for byte. That disagreement is re-checked on every run rather than written in: if a later release starts matching, the cross-implementation check for SHAKE-256 begins running by itself. Also asserts what a suite must NOT do — neither ciphersuite accepts the other’s signature, and the same key material gives different keys in each, because nothing in a key or a signature says which suite it belongs to. No browser and no services, so it never skips. Note the@noble1.4.0pin intests/package.jsonis load-bearing here, for the reason intests/CLAUDE.md)digital_signature.js(the Digital Signature page, top to bottom, needing no IdP and no api: SLH-DSA’s 12 parameter sets, RSA at two key sizes × both paddings × every hash, ECC over four ECDSA curves × every hash plus EdDSA/Schnorr/BLS, ML-DSA’s three sets, BBS in both ciphersuites, the three symmetric MAC families, and every keystore format on every pane including the deliberately-unsupported combinations, which must report a clear message rather than emit a broken file. The BBS pane is the one that needs more than “sign, then validate”: it signs a message list, so the test also asserts what the draft binds — a changed, reordered, dropped or added message and a changed header must each be refused — then derives proofs and checks selective disclosure, unlinkability (two derivations of one signature must differ and both verify), and the three refusals a proof owes: a replay under another presentation header, a substituted disclosed message, and a different disclosure set. It finishes by driving the draft’s own vectors through the page in hex mode, which is what says the pane’s field handling produces the draft’s bytes rather than bytes the page agrees with, and by confirming a signature made under one ciphersuite does not verify under the other. Two details in the pane bit the first draft and are asserted: one trailing newline is a line terminator while a further one is an empty message — the draft’s multi-message vector ends with exactly that, and dropping it makes the vector unreproducible — and every in-progress status ends with an ellipsis, so a wait for “settled” tests for a trailing one rather than for an ellipsis anywhere, since finished messages quote ranges like(0…3). Mutation-tested, five ways, each caught by the assertion meant for it: giving the SHAKE-256 suite the SHA-256 suite’sP1— the interesting one, because everything the page checks against itself still passes and only the draft’s vectors notice; making Validate always answer VALID; making Verify Proof always answer VALID; dropping every trailing blank line from the message list; and removing the fresh randomness from proof derivation, which the unlinkability check catches. Roughly 35s of the job’s runtime for both suites. Two more panes since 2026-08-24, and for both of them this job proves the WIRING while a node job proves the bytes — the file says so in its own header, because a round trip through the page agrees with itself whatever the implementation does. JWS: all sixteen registered algorithms through all three serializations; the JSON-payload refusal the pane exists for, in both directions; a tampered payload; a detached payload (the middle part must be empty, and Validate reads the payload back out of the box); RFC 7797’s unencoded payload, including the compact refusal a JSON payload triggers on any decimal number, and the assertion thatb64:falsereached the PROTECTED header ANDcrit; the unprotected header and the refusal to put one in a compact JWS; typ/cty/kid/extra members and an embeddedjwkthat must not carryd; an algorithm the verifier did not choose being refused (RFC 8725 §3.1); and a shared secret read as hex, base64url and text, since reading one as another signs under a key that is not the one on screen. XML Signature: the well-formedness refusal; all three signature types; a modified document; the four canonicalization methods with the assertion that WithComments really changes the reference digest, which needs a sample document containing a comment and is otherwise a control that does nothing; every DigestMethod; RSASSA-PSS, ECDSA over four curves (checking adsig11:ECKeyValueis what carries the key) and HMAC (checking a KeyName is); an X509Data KeyInfo over a key that has no certificate being refused; both XPath transforms, which only a browser can run — they are evaluated by the DOM’s own XPath engine, and the Filter 2.0 case is proved in BOTH directions, that a subtracted subtree can be changed freely and that everything kept cannot; the base64 transform and the note it forces (it ends the chain, so the Reference canonicalization is dropped and the pane says so); every KeyInfo form including none at all; the InclusiveNamespaces PrefixList reaching the document; and a missing enveloped-signature transform being added and reported, since one that is silently absent produces a signature that can never verify)encryption_tools.js(the Encryption / Decryption page, top to bottom, needing no IdP and no api: 17 symmetric algorithms across three panes (AES in six modes at three key sizes, ChaCha20-Poly1305 and the raw keystream, 3DES three-key/two-key and DES), RSA in both paddings in both modes, ECIES over five curves, ML-KEM at three parameter sets alone and hybridised with X25519, ElGamal and DHIES over both MODP groups, five JWE algs, and four password KDFs. Every one round-trips through the page and then fails on purpose: a modified ciphertext, a changed AAD, a truncated tag, a key of the wrong length, a key that is not hex, an odd number of hex digits, an over-long direct-mode message, the wrong recipient’s key, the wrong password, a non-power-of-two scrypt N, an empty password, a modified JWE, and PKCS#12 with no password — 26 deliberate refusals, each asserted on the SENTENCE it produced rather than on the operation not happening, because a pane that says “error” for all of them is a pane you cannot debug with. What it deliberately does not check is whether the bytes are right — encrypt-then-decrypt agrees with itself whatever the implementation does, so that iscrypto_engines.js’s job and the two files say so to each other. Also asserts the page’s claims about itself: nine panes all starting collapsed, the algorithm lists complete (an option added without a case here fails), each pane naming whether its cipher is authenticated, ECB leaving its IV box empty, an AEAD leaving no tag where there is none, the ephemeral value differing between two encryptions, the JWE header naming what was actually used and carrying anepkfor ECDH-ES, the PBES2 JWE carrying its own salt and iteration count, and the Tools-pane links on both debugger pages reaching here with a return link that follows?from=and falls back rather than reflecting an unknown one. Four traps are recorded in the file because each cost a run: the expand-all checkbox is visually hidden and only its slider is clickable; every in-progress status ends with an ellipsis and nothing else may, which is how “still working” is told from “finished”; a status line left from the previous action satisfies the next wait instantly, soclickButton()andselectOption()blank it before acting rather than leaving thirty call sites to remember; and the console check must separate the page’s OWN error records — bunyan’s shim, one per deliberate refusal — from an uncaught fault, since demanding an empty console fails on the page working correctly. ~3 minutes)- Post-quantum additions to the two tool-page jobs (2026-08-28).
digital_signature.jsgained a page-structure check it never had — the eleven fieldsets and the JWS pane’s twenty-sevenalgvalues are both asserted against a list in the test, so a pane or an option added without a case fails loudly. That assertion exists because its absence had already cost something: a whole Composite ML-DSA pane reached the page and was driven by nothing. Beside it:testCompositedrives all six composite algorithms and then the negative the construction exists for — a signature whose traditional half is corrupted must be REJECTED while the status still reports the ML-DSA half as verifying, since “invalid” alone would hide the difference between a stripped component and a corrupt message, and a truncated signature must read MALFORMED rather than invalid because nothing was verified.testPqVariantsdrives the two controls both post-quantum panes gained, checking that they are ALGORITHM CHANGES and not options: a signature made under contextapplication-Amust not verify underapplication-Bnor as a pure signature, and a SHA-256 pre-hashed signature must be refused both by a SHA-512 verifier and by a pure one. The eleven post-quantum JWS algorithms are signed and validated with the protected header asserted to carry the registered identifier verbatim — RFC 9964 and the two drafts register those strings, so a dropdown label leaking into the header would interoperate with nobody.encryption_tools.jsgained the same shape of structural guard for its key-agreement panes, which had none: every selector named inAGREEMENT_PANESis now asserted against the options the page offers, and the ML-KEM pane drives thirteen explicit combinations rather than a cartesian product — every parameter set for the two modes that use one, plus each of the seven standardised hybrids once, since a standardised hybrid fixes its own parameter set and crossing them would be fourteen runs with an ignored selector. That guard was added after the seven new modes shipped and round-tripped in nobody’s test. crypto_engines.js(the same page’s cryptography in node, with no browser, and the file to read before adding a correctness check to the job above. It asserts against things that are not this code: RFC 8439’s ChaCha20, Poly1305 and AEAD vectors — the tag especially, since a construction that forgot the AAD padding or the two length counters passes every round-trip test ever written and interoperates with nothing; RFC 4493’s AES-CMAC vectors; the SipHash-2-4 reference vectors at every tail length; NIST SP 800-38A’s AES-256 vectors for ECB, CBC, CFB, OFB and CTR; the FIPS 81 single-DES vector plus 3DES(K,K,K) == DES, which is what says the E-D-E ordering is right; FIPS 203’s key sizes for all three ML-KEM parameter sets; PKCS#1’s own size formulae for both RSA paddings at every OAEP hash; and node’s own OpenSSL for thirteen ciphers and for RSA-OAEP, in both directions — ours out and theirs in, because one direction alone misses a padding convention that is wrong symmetrically. Single DES is skipped there and SAYS so: OpenSSL 3 moved it to the legacy provider, and a required-list assertion stops the cross-check quietly shrinking to nothing. It also runs Miller-Rabin over RFC 3526’s transcribed primes and checks(p-1)/2, since a mistyped digit gives a group that works perfectly between two copies of this code and is not the one anybody else uses; checks the P-256 ECDH secret against node’screateECDH(it is the x COORDINATE, not the compressed point @noble returns — feeding the whole point to the KDF interoperates with nothing); proves the ML-KEM hybrid genuinely needs both halves rather than being a label; and asserts that the three modules under test touch no DOM, which is the property the whole job rests on. It caught two defects on the way in:pemToDer’s-----[^-]+-----strip cannot cross the hyphen inSLH-DSA PRIVATE KEY, andpemLabelhad the same assumption. ~15s)-
jws_engine.js(the Digital Signature page’s JWS pane in node, and the file to read before adding a correctness check to that page’s Selenium job. It exists for the same reasoncrypto_engines.jsdoes — sign-then-verify agrees with itself whatever the implementation does — and the defects it is aimed at are the self-consistent ones a JWS invites: an ECDSA signature left as the DER SEQUENCE every crypto API returns where RFC 7518 §3.4 wants the RS concatenation padded to the coordinate size; a PSS salt that is not the hash’s own length; a payload re-serialized between validating it and signing it, so that the octets signed are not the octets sent; base64 where base64url was meant. So every registered algorithm is cross-checked against node’s own OpenSSL in both directions — ours out and theirs in, because one direction alone misses a convention that is wrong symmetrically — routed through the module’s OWN JWK export, which makes that export part of what is tested: a wrong crv, a mis-padded coordinate or base64 instead of base64url all stop node importing the key at all.jsonwebtokenis a third opinion on the twelve algorithms it covers, and reads a JWS the way an application does rather than the way a crypto library does. The PSS salt length is asserted explicitly and mutation-checked, since OpenSSL’sRSA_PSS_SALTLEN_AUTOverifies any salt: the test also demands that the wrong length be REFUSED, or the assertion above it would be vacuous. Then the rules no vector can express — RFC 7515 §4.1.11’scritMUST (an extension the producer marked critical and this code does not implement means the JWS is rejected, and the refusal has to name what it is about); RFC 7797’s period rule and itsb64-in-critrequirement; RFC 8725 §3.1, that the verifier decides the algorithm; that an unprotected header member is genuinely NOT covered while every protected one is; that EdDSA’s two curves are told apart by the KEY, since RFC 8037 puts the curve nowhere in the header; that an Unsecured JWS has an empty signature and that a non-empty one is refused; that an embeddedjwknever carriesd; and that the payload comes back byte for byte, indentation included. It also assertsjws.jstouches no DOM, which is the property the whole job rests on — the check strips string literals first, because “it must be a JSON document.” is prose and a check that cannot tell prose from a DOM access gets weakened the first time it fires on one. . Since 2026-08-24 it also carries the two checks the JWS CONSOLIDATION rests on. Key forms: this module absorbed the key handling of five pages, so every form is checked by making node produce the key and this module consume it — PKCS#8 and SPKI PEMs, JWKs in both directions, an EC coordinate a conforming publisher trimmed a leading zero from, a real X.509 certificate (which both JWT pages promised in a label and could not actually read), a JWK Set resolved bykid, a set with one usable key and none, a set that is genuinely ambiguous and must say so rather than guess, and a secret in all three encodings — including the assertion that reading a hex secret as text does NOT verify, since that difference between two pages was silent. Backends: the module has a pure-JS backend and a Web Crypto one, and four workflows that were already signing withcrypto.subtlenow go through it — so each backend must verify the other’s signature, and for every DETERMINISTIC algorithm the serialized token must be identical byte for byte. That is the only thing that could have caught a changed DPoP proof or credential proof, because none of those workflows’ own tests compare bytes. It also pins that a supplied header is serialized verbatim (member order is part of the token) and thatsignJws()refuses a CryptoKey by name rather than returning a promise where a value was expected. One defect came out of writing it: a JWK-Set test readinput.keysfor truthiness, and everyUint8Arrayhas akeysMETHOD — so an HMAC secret was reported as “That JWK Set has no keys”. ~25s) pqc_engines.js(the post-quantum cryptography, in node with no browser, and the third job of thecrypto_engines.js/jws_engine.jsshape. Its reason for existing is sharper than either: almost nothing else in the world can read these bytes yet, so a pane that signs and verifies its own ML-DSA is exactly as convincing when the domain separation is wrong — self-agreement is the expected state here, not a corner case. So it asserts against things that are not this code. draft-connolly-cfrg-xwing-kem-07’s own three test vectors for X-Wing, checking the public key derived from the seed, the derandomized ciphertext and the shared secret — which matters because X-Wing is implemented here from the draft’s pseudocode rather than taken from a library (the one that ships it is ESM-only and this bundle is CommonJS), and a vector match is a stronger statement than provenance. The domain-separation labels of both composite drafts against the hexadecimal those drafts print —draft-ietf-jose-pq-composite-sigs-03’s six signature labels and itsCompositeAlgorithmSignatures2025prefix, anddraft-ietf-lamps-pq-composite-kem-08’s two printed KEM labels including the six-byte5c2e2f2f5e5cthat JavaScript string escaping silently shortens. node’s own SHA3 for the KEM combiner, which is what catches a reordering of its five inputs (a round trip cannot: both ends would reorder identically). And every transcribed size row against a key the library actually generated, since@noble/post-quantum0.4.1 exposes no metadata at all and the composite splitters depend on those numbers. Then the properties no vector expresses: that a composite signature needs both halves — with the ML-DSA half replaced, and again with the traditional half replaced, plus a check thatcomponents()names the right one, because a composite that verified on one half would be strictly worse than the stronger component alone; that a context string genuinely separates contexts and that a signature made under one is refused under another and as a pure signature; that HashML-DSA with SHA-256 is not accepted by a SHA-512 verifier nor by a pure one; and RFC 9964’s AKP rules, which this project got wrong for months —pub/privrather than the OKP namesx/d,algREQUIRED, and an ML-DSAprivthat MUST be the 32-byte seed (with the expanded key refused by a message that says so, and the six composite key types not caught by that rule, which an earlier/^ML-DSA-/test on the algorithm name got wrong). Finally it asserts the metadata contract the whole module exists for: every algorithm names its specification, every draft carries the note the pages render, every note names a revision, and the deliberately-absent algorithms — FN-DSA, which has a specification but no implementation this bundle can load, and HQC, which has neither — are recorded with reasons rather than left as silent gaps. Three of its guards were mutation-tested before it was committed: a one-character label change, a composite verify relaxed to||, and the unpadded Falcon variant were each caught. No browser and no services, so it never skips; about 60s)hash_engine.js(the Hashing / Encoding Tools page’s hashing, in node with no browser, and the file to read before adding a correctness check toencoding_tools.js. It exists for the reasoncrypto_engines.jsandjws_engine.jsexist, in its sharpest form: a digest is the one value where being wrong looks exactly like being right — 64 hex characters that change completely whenever the input does — so a page can be confidently, self-consistently wrong and nothing on the screen says so. It asserts against things that are NOT this code. node’s own OpenSSL for all eleven fixed-output functions (SHA-1, SHA-224/256/384/512, SHA-512/224, SHA-512/256, SHA3-224/256/384/512) over seven inputs each, and for both SHAKEs at five output lengths.openssl mac ... KMAC128/KMAC256, which is the ONLY second implementation available for the SP 800-185 half — neither Web Crypto nor node’s hash API has cSHAKE, KMAC, TupleHash or ParallelHash, so without the OpenSSL CLI that half would be checked only against transcribed vectors; it SKIPS with a loud message on OpenSSL 1.x rather than passing quietly, and it keeps its keys at 4 bytes or more because OpenSSL’s KMAC provider refuses shorter ones although SP 800-185 allows them. Fifteen sample values transcribed from SP 800-185’s appendix. And the constructions re-derived from that document’s own text —left_encode,right_encode,encode_string, and TupleHash and ParallelHash rebuilt out of cSHAKE from them — which is what catches an encoding that is wrong and self-consistent, the way these four functions actually fail. Then the properties no vector expresses: that cSHAKE with an empty N and S IS SHAKE (section 3.3), that a SHAKE’s longer output extends its shorter one while a KMAC’s does NOT (the length is bound in withright_encode(L), which is the whole difference between KMAC and KMAC-XOF, and getting it backwards produces a MAC that verifies against itself and nothing else), that TupleHash separates two tuples sharing a concatenation, that ParallelHash’s block size changes the answer, and that legacy Keccak is not SHA-3 on any input — the pane offers it because tools that predate FIPS 202 call the pre-standard padding “SHA3”, which is the commonest way a CORRECT SHA-3 implementation gets reported as broken. It also asserts the NOTES, which nothing else could: that both resistances are stated separately, that what Grover moves (preimage) is distinguished from what it does not (collision), that an XOF’s strength is capped by its capacity however much output is asked for, and that the four functions whose collision search DEFINES a NIST post-quantum security category say so and no others do. Finally the two properties an edit takes away silently: thathash_tools.jsreaches no DOM, no storage and no Web Crypto — which is what lets this job exist — and that the PAGE and the registry still offer the same functions and every handler the markup calls is exported, since an option whose value is not a registry id produces an error in a status line nobody is watching and a renamed handler is a ReferenceError on click. It loads its subject out of/usr/src/client/srcrather than from a flat copy, asrfc9700_client.jsdoes. No browser and no services, so it never skips; about a second)hbs_signatures.js(LMS/HSS and XMSS/XMSS^MT — the two STATEFUL hash-based signature schemes SP 800-208 approves — in node with no browser, and the job in this directory with the most to prove. Every other signature scheme in this application is somebody else’s implementation with a thin layer over it; there is no LMS and no XMSS in@noble, in Web Crypto or in node, soclient/src/hbs.jsis written FROM RFC 8554, RFC 9858, RFC 8391 and SP 800-208 — and hash-based signatures are the worst possible subject for that, because every interesting mistake produces a scheme that signs and verifies AGAINST ITSELF perfectly and interoperates with nothing. One of them was real here: the WOTS+ chain address was written into ADRS word 4, where the LEAF index lives, which produced a complete and self-consistent XMSS that no other implementation would have accepted, and the first reference vector caught it. So every vector comes from OUTSIDE this tree. RFC 8554 Appendix F’s two HSS cases and RFC 9858 Appendix A’s three, covering W4 and W8, H5 and H10, L = 1 and L = 2 and all four LMS hash functions including both truncations. cisco/hash-sigs’s LM-OTS vectors, which publish I, q AND SEED and so exercise RFC 8554 Appendix A key generation rather than only verification. One verification vector for each of the 21 XMSS parameter sets in the IANA registry, so a parameter set the pane offers and nothing has checked fails the job by name. Key generation and XMSS^MT vectors generated deterministically from the XMSS reference implementation — the code that accompanies RFC 8391. Nothing publishes an XMSS^MT test vector at all (Botan does not implement the multi-tree variant, that repository ships no KAT files, and RFC 8391’s appendices are XDR formats rather than examples), so nine were generated from a fixed seed and all nine matched on the first run, key generation and verification both;docs/hbs.mdcarries the procedure to regenerate them. They are also the only thing that can pin SP 800-208 section 6.2’sPRF_keygenand the FOUR-byte function padding of the 192-bit parameter sets, both of which a verifier never touches and neither of which any name reveals — and because their trees are 32 leaves rather than 1,024, every hash function and every padding length is now covered on EVERY run rather than behind a flag, which the job asserts rather than assumes. And eight signatures that must not verify. Then the properties no vector expresses: that the p, ls and len tables the module COMPUTES agree with the ones the RFCs print; that all four registries carry the IANA identifiers, because an identifier is what appears on the wire; that a bit flipped in the leaf index, the randomizer, a Winternitz chain or the authentication path is each refused; and THE STATE — that the index advances, that signing again with an un-updated key blob re-spends the same index (the operational hazard, made visible rather than prevented), that a key with all 32 leaves spent refuses a 33rd message, and that two different messages signed from ONE index both verify, which is the failure SP 800-208 section 1 exists to prevent and the reason the obligation sits on the signer rather than anywhere a verifier could check. And then it breaks the module on purpose, seven ways, and requires the vector each mutation is aimed at to notice — the padding rule, both WOTS+ address words,D_LEAF, the checksum shift, LM-OTS’s0xffseparator andPRF_keygen’s — because every other check in the file is of the form “this vector reproduces” and none of them says any particular line is load bearing. It loads a mutated COPY from a temp directory rather than writing intoclient/src, where a leftover file would be swept up byjwk_pem_encoding.js, and each mutation asserts its target text was present before substituting, since one that silently fails to apply makes the check it anchors pass for the wrong reason. Two of the seven earned their place at once: thePRF_keygenone breaks key generation and leaves VERIFICATION working, which is the whole argument for having obtained key generation vectors; and the checksum one survived its first aiming, becauselsis 0 for every w = 8 parameter set and four of the five published LMS vectors are w = 8 — it now finds a vector whose LM-OTS set actually has a shift, and throws if the vector set stops containing one. It also asserts what the module must not do: reach a DOM, and log inside any of seven hot-path functions — onelog.debugpair inwotsChain()is tens of millions of records for a single key, at the debug level both test stacks configure. Five SINGLE-TREE key generation vectors are deferred behindHBS_ALL_KEYGEN=1and the job names them: at 1,024 leaves they take about 160 seconds between them, and they gate nothing — they re-test on a full-size tree the rules the 32-leaf XMSS^MT vectors check on every run. No browser and no services, so it never skips; about 40 seconds)webauthn_decode.js(the WebAuthn decoder —client/src/cbor.js,cose.jsandwebauthn.js— against real ceremonies: ES256 and RS256, registration and assertion, produced by Chrome 121’s WebDriver virtual authenticator and committed astests/webauthn_vectors.json, so the test needs no browser, no network and no hardware, and never skips. Two oracles, neither of them ours: the browser’s owngetPublicKey(), which the COSE_Key → JWK → SPKI chain must reproduce byte for byte, and node’scrypto.verify, which checks the same signatures from the JWK our COSE decoder produced. CBOR is checked against RFC 8949’s appendix A, and refuses indefinite lengths (CTAP2 canonical CBOR forbids them), duplicate map keys, a declared length longer than the input, trailing data and runaway nesting. The negatives each fail exactly ONE named check — tampered authenticator data, tampered client data, challenge, origin, RP ID hash, sign-count regression — and the interesting one is user verification: a UV-clear assertion must be rejected on the flag while its signature stays valid, paired with the same assertion passing when UV is not required, because reporting it as a bad signature would send the user after the wrong thing entirely. Mutation-tested: removing the DER→raw ECDSA conversion breaks every ES256 check while RS256 stays green, which is the exact signature of that bug)did_tools.js(did-tools.htmlthrough a browser, against DIDs generated by the mock STS:GET /did/generate?method=jwk|webhands back a DID together with a credential signed by the key that DID publishes, so the page’s verdict is checked against a known-good answer rather than against “the document parsed”.did:jwkexercises the local-decode path with an ES256 credential — a pass cannot come from accidentally verifying against the service’s RSA key — anddid:webexercises a real retrieval plus the domain linkage. Also asserts the page writes nodid_*key belonging to the issuance workflow. Its tampered-signature negative flips a decoded byte, not a base64url character: the first version edited the last character, which is a silent no-op whenever the signature’s length leaves padding bits there — several characters decode to identical bytes. It passed locally and failed in the suite, in 2 of 20 credentials when measured. Needs the STS and the client. Every one of its twelve navigations goes throughpage_load.js’sloadPage(), which is not decoration: a dropped connection on any one of them used to fail the job naming the DID pane, which is what took this job red against idptools.com on 2026-09-10. Mutation-tested, and the first round found a hole: making Verify Signing Key always answer VERIFIED ESCAPED, because the mismatch negative stops at “this document describes a different subject” and never reaches the signature check. It now also plants a credential whose issuer matches the document but whose signature is tampered, which is the case that reaches it)- The mock STS’s own
/adminconsole and its/admin-apimanagement API were driven from here by four jobs —sts_metadata.js,admin_api.js,sts_admin_api_operations.jsandsts_admin_console.js— until 2026-08-28. All four asserted things about the mock rather than about this debugger, and the mock’s own suite drives them now, in the tree where a change to that console is made. Nothing here replaces them: a test of this project’s pages belongs here, a test of that service’s console belongs there. -
sts_persistence_postgres.js— moved to iya-sts on 2026-10-23 (tests/vendored/sts_persistence_postgres.js, iya-sts #938). It tested that service’s postgres persistence, not the debugger, and runs there against the test stack’s own postgres in every mode. did_document.js(the wallet’s DID module,client/src/did.js: the three methods, reading a document,resolveVerificationMethod’s two forms, and the DIF domain linkage check with nine negatives — each asserted to fail the one check it should, since a verifier that rejected everything would pass a test that only looked at the verdict. No browser and no services, so it never skips. It found the truncated P-384/P-521 curve constants on its first run; mutation-tested: overwriting a key’s ownkidinassertionJwks, and treating “this origin links some DID” as “it links the DID asked about”, are each caught)vc_did.js(the issuer named by DID, end to end against the STS mock: the advertisement, the DID document, the domain linkage, and then — for bothdc+sd-jwtandldp_vc— that the credential names the DID and that the key resolved from its document verifies the signature or the BBS proof. A DID that resolves to the wrong key looks like success until exactly that last step, which is why the chain is walked rather than spot-checked. It also asserts the plain siblings still name the issuer by https URL, because a suite where every credential had switched to DIDs would have stopped testing the mechanism SD-JWT VC actually defines. Mutation-tested: a DID configuration that does not use a DID, anissuer_identifierhard-wired to the https base, a linkage credential naming another origin, and anldp_vcverificationMethodleft as an https URL are each caught by the assertion meant for it)spiffe_engine.js(the SPIFFE workflow’s engines, in node, with no server and no browser. It needs NOTHING — not the api, not the mock, not Chrome — so it is never gated and runs on every target including the static ones, and it is FIRST of the four SPIFFE jobs deliberately: a broken address rule or a wrong catalogue makes the other three fail in ways that look like a broken server. Six sections, three of which only work from outside the code they check. The ID grammar against the specification’s own rules, written out here independently — a table derived from the module would agree with it by construction — including the four ways the grammar is stricter than a URL parser, each of which produces an identifier that looks perfectly fine in a log: a lower-case trust domain (new URL()lower-cases a host for you, which HIDES it), a path that is not a URL path, no port/userinfo/query/fragment, and/spirereserved on a SEGMENT test rather than astartsWith, so/spiremanis not under it. The bundle reader against documents wrong in one way each, and the one that matters is asserted as an ERROR rather than a warning: a JWK with nouseis one a consumer MUST IGNORE, so a bundle of them verifies NOTHING and reports no error anywhere. The 49-method catalogue against the vendored protos BOTH WAYS ROUND — every derived method carries a note and every note names a method that exists, without which a note could name a method the protos no longer have and nothing would say so. Those protos against the mock STS’s copies, byte for byte, all 21 files — the whole reason@grpc/grpc-jsis a dependency is that the wire matches what a real client expects, so an edit to either copy would make this debugger agree with that mock and interoperate with nothing; the mock’s directory is found through its ownspiffe_grpc.jsrather than by namingspiffe/protos, because that service moved every module into folders on 2026-08-23. Every address and socket refusal by its CODE, not by “it was refused” — the four checks bounding a Unix socket path each catch most of what the others catch, so a test asserting only that a path was refused stays green with three of them removed. And a PKCS#10 certification request in four key algorithms verified with OPENSSL, which is the only kind of check that catches an encoding that is wrong and self-consistent; noteopenssl req -verifywrites its verdict to STDERR, so reading stdout alone fails every request this codebase produces and looks exactly like the defect being hunted.)spiffe_protocol.js(all forty-nine SPIFFE methods actually SENT, against the mock STS, through the api’s own client driven in process.spiffe_engine.jsasserts the catalogue HAS forty-nine; this file calls each one, which is a different claim and the one that matters — a method can be in a list, in a picker and on a page and never have been sent, so the floor at the bottom counts METHODS SENT rather than assertions. It acquires FOUR identities in order, because this surface authorizes every method against what the caller IS: nothing (GetBundleandAttestAgent, which must be open — an agent has no SVID until that call gives it one, which is why the port asks for a client certificate and does not require one), a workload (FetchX509SVIDwith no credential at all, the bootstrap), an administrator (that same SVID oncespiffe.adminIdsnames it — the only route from “I can fetch an identity” to “I can drive the registry” that does not already require one), and an agent (AttestAgentwith a join token and a real CSR). Fifteen of its checks are negatives and each asserts the CODE:UNAUTHENTICATEDwhen nothing was presented andPERMISSION_DENIEDwhen something was and it was not enough are different instructions to a client, andDebug.GetInfois refused even to an administrator over TCP because SPIRE’s own table marks it local-only — that surprise is the point rather than an omission. It holds thests-spiffelock: it setsspiffe.adminIds, shortensspiffe.svidTtlto watch a rotation and turnsspiffe.autoCreateEntriesoff, each read first and restored PER SETTING in afinally— neverreset-all, which would undo whatever a concurrent job had pinned. Three things about it are load-bearing and each cost a run while it was being written. Ordering: rotation runs BEFORE the no-identity section, because that one deletes the invented entry and leaves the setting off until the restore, so a stream held afterwards is answered with an empty SVID list and fails as “no certificate came back”. TurningautoCreateEntriesoff is not enough: selector matching narrows to entries whose selectors are a SUBSET of the caller’s, and an earlier call has already caused an entry to be invented carrying this caller’s own stable selectors — asserting an extra selector only WIDENS the caller’s set, so there is no request that would miss it and the invented entry has to be deleted. And a join token is abytesfield, so it goes on the wire as base64; sent as text, protobufjs base64-decodes it and the far end refuses something shorter and different, naming the token rather than the encoding. Its local-socket section needsSPIFFE_SERVER_SOCKET— the mock ships that socket OFF, anddocker-compose-run-tests.ymlturns it on and shares it through a volume, becauselocalis a whole entity class nothing else can reach; on a host run it skips with that reason rather than passing vacuously. Its gate additionally requires the mock to have BOUND the two gRPC ports it is about to dial (tests/spiffe_listeners.js): those listeners are optional to that service, so a stranger holding 8181 leaves this job writingspiffe.adminIdsto one process over HTTP and exercising it against another over gRPC — which it reported as the far end’s own authorization refusal, naming a rule instead of a port.)api_spiffe.js(the api’s three SPIFFE endpoints over HTTP, and the status-code rule that exists nowhere else. A refusal by the api is a 400 with its own code; a network failure is a 502, and so is a server that answered and turned out to be somebody else — flaggedidentityMismatch, because the chain verified and that is a different fact from “nothing was there”; and a gRPC status from the far end is a 200 withok: falseand the code. That last one is why this file exists:PERMISSION_DENIED,UNAUTHENTICATED,UNIMPLEMENTEDwith the reason a server gives,INVALID_ARGUMENTon a JWT-SVID request with no audience are all SPIFFE ANSWERING, and an api that reported them as failures would put the most interesting half of this workflow behind an error page. It also asserts thatGET /spiffe/limitspublishes all forty-nine methods DESCRIBED — the page’s picker is built from it — and that a 502 SETTLES rather than hanging, which on this endpoint is the failure that matters most since pointing it at a host that may not be there is the point. One thing it caught: an address routed nowhere does not refuse a connection, so the call runs out the budget and answersDEADLINE_EXCEEDED— which without the message count would have been reported as a 200 describing an answer that never existed. Its gate requires the mock to have BOUND 8092 and 8181 (tests/spiffe_listeners.js) for a reason peculiar to this file: it is the one of the three that a stranger does not break, because a status-code contract is true of any instance of the same mock — so on 2026-09-01 it passed while the two beside it failed, and a green job is the last place anybody looks for a stolen port.)spiffe_page.js(the SPIFFE page through its own controls, covering only what needs a browser. Four things live here and nowhere else. All forty-nine methods reach the two pickers — the whole claim this workflow makes is a claim about those dropdowns, and a page offering forty-two of them would look completely normal. The SVID hand-off:FetchX509SVIDneeds no credential and returns an SVID WITH ITS PRIVATE KEY, and the page keeps the pair and presents it on a surface that requires mutual TLS — nothing in the protocol notices if that hand-off drops the key, because the call simply becomes anonymous and anonymous still WORKS on the two methods open to everybody, so the failure reads as an authorization problem on a server. The PKCS#10 request built in the browser with Web Crypto, a different implementation from the node onespiffe_engine.jschecks against OpenSSL, and without it five of the forty-nine methods are unreachable in practice. And the key-material opt-out, which must REMOVE a stored private key rather than only stop writing one. Plus the three offline readers — the trust bundle one is a GROUP in the settings pane since 2026-08-26, not a pane of its own — and the stylesheet checktests/navigation.jscannot make for a page it can reach only when the api is up. Note its status lines are<input>s, so their content is their VALUE —wait_for.js’swaitForStatusreads text and would wait out its whole budget reporting(empty)on a status the page filled in immediately — and every status is blanked BEFORE the action that fills it, or the previous action’s message satisfies the wait instantly. Its preconditions require the mock to have BOUND the two gRPC ports the api will dial (tests/spiffe_listeners.js), and that check is a FAILURE rather than a skip: the trust bundle comes over HTTP and the hand-off over gRPC, so a stranger on 8181 leaves this job presenting an SVID from one authority to a server holding another — which the api reports asself-signed certificate in certificate chain, a message about TLS three services from the cause.)api_ldap.js(the LDAP protocol through the api’s eightPOST /ldap/*endpoints, against the mock STS’s embedded directory over a real TCP socket. No browser: RFC 4511 is BER on a socket, so the whole protocol lives in the api and nothing about the exchange is visible from a page —ldap_page.jscovers the page, and keeping them apart means a failure names which of the two broke. The ten operations the workflow exists for are the easy half; what earns this test its keep is the three things a happy-path version would pass without. An LDAP result code is not an HTTP error —noSuchObjecton a DN that is not there is a completed round trip whose answer was “no”, so every negative asserts the STATUS (200, withok: false) as well as the code; answering 500 would put the most useful half of this workflow behind an error page. The scope has to be the scope asked for — a wrong scope returns a SUPERSET, so every assertion about the contents still holds and only the count differs, which is invisible in exactly the direction that makes it hardest to find;oneandsubare therefore compared against each other rather than each being checked for “some entries”, and it had already happened once in the mock, where ldapjs spells the scopessingleandsubtree. Membership is a modify on the GROUP, asserted from both ends — the members of a group are read from the group, the groups of a user are found by searching the groups for amembervalue naming the user — because an implementation that got one direction right and the other backwards looks correct until somebody asks the other question. Plus a presence filter ((objectClass=*)matched NOTHING here once, because @ldapjs/filter compares the attribute name case-sensitively by default, and the symptom was a successful search returning zero entries — which reads as an empty directory);compareanswering 6 or 5, neither of which is success; the mock’s two teaching properties, that any password binds except the literalinvalidand that deleting a user leaves it dangling in its groups; and the auto-created user, which is one hook onadmin_stats.recordAuthentication()and so cheap to break — driven over the OAuth2 password grant in section 9, and over WS-Trust in section 9b, which is not redundancy: the hook being installed and a protocol REACHING the funnel are two properties that fail separately and present identically, as somebody who authenticated here and has no directory object. WS-Trust failed the second one three ways at once and section 9 passed the whole time —ValidateandCancelanswered before the request was authenticated at all, a request carrying both a UsernameToken and anOnBehalfOfdropped the requester, and aRenewwith no security header read the assertion out of its ownRenewTargetand recorded that as the credential. So 9b drives all four WS-Trust operations, both shapes of a delegated request, and the RenewTarget negative, which is there because the careless fix for the other two is to seed an entry for whatever identity the document happens to contain first; all three groups were checked to fail against the unfixed mock. Section 9c does the same for WS-Federation, which is the other shape of authentication here — a name typed at a screen, a browser session, and an assertion issued on a later pass presenting no credential at all — so what it pins is that the one moment it happens,authn.js’sstartSession(), reaches the funnel; it drives the screen over two plain HTTP calls rather than a browser, since the pending request is held under the form’ssignin_idrather than in a cookie. And all three sections now assertautoCreateUsersrather than skipping with a warning when it is off, which is the change that matters most: all three of the mock’s env files carriedautocreateUsers: false, an appconfig value beats the default, and this file’s own check opted out of saying so on every run it ever made. Since 2026-08-18 it also builds a populated directory — 20 users, 10 groups, two unique users in each, and one of the twenty in all ten — because everything above it is one user and one group, which is the right shape for asserting what an operation MEANS and the wrong shape for asserting anything about a set. Three defects survive the one-of-each workflow untouched. A search that silently returns fewer entries than matched: three caps sit between the test and the store (the api’sldapMaxEntriesandmaxContentLength, the mock’sLDAP_SIZE_LIMIT) and the honest answer to reaching one issizeLimitExceeded(4), while the dishonest one is a truncated list with result 0 that is indistinguishable from a directory holding exactly that many — invisible with one entry, onestrictEqualwith twenty. Membership that is not per-group: ten groups of two will pass “the group contains the user” for every group even if the modify landed on the wrong one, or on all of them, so each group’s member list is compared for EXACT equality against the pair that belongs in it and a superset is a failure. The reverse lookup at fan-out: the pivot is in ten groups and every other user in exactly one, so a(member=...)filter matching too much and one matching too little give different wrong answers — with one user in one group they give the same right one. The pivot is deliberately one of the twenty rather than a twenty-first, which makes one of its ten joins a re-add of a value already present: RFC 4511 section 4.6 makes thatattributeOrValueExists(20) and this mock answers success, filtering the value instead, so the divergence is asserted rather than avoided — a client that learns “re-adding a member is harmless” here meets 20 the first time it points at a real directory. It cleans up its thirty entries (groups first, so the dangling member stays section 7’s lesson, asserted once) and both mutations have been checked to fail it: keeping duplicate member values, and truncating a search silently. Every name is unique per run — the mock’s directory lives for the life of its process, so a fixeduidisentryAlreadyExistson the second run — and every entry is removed again, with one deliberate exception. Since 2026-08-19 the file’s last section leavescn=test_usersbehind, holding every entry underou=usersat the moment it runs (the seeded three plus everybodyLDAP_AUTOCREATE_USERShas added by then, ~25 after a full run), because what all that cleaning up leaves is three people and two groups — the right size for asserting what an operation MEANS and far too small to try the page’s searches, presets and membership panes against by hand. It is a fixture, so it inverts three of this file’s rules: the name is FIXED, since one nobody can predict is not a fixture, and it pays for that by being idempotent — added when absent,memberREPLACED when present, replaced rather than added to because anaddaccumulates the union of every run including the DNs of departed users; it is a snapshot and not a dynamic group, since the jobs after this one authenticate too; and the search it is built from is asserted UNTRUNCATED before the list is used, because a group written from a capped search matches exactly the short list it came from and is indistinguishable afterwards from a directory that held that many. Needs the api and the mock STS; not gated onLDAP_AVAILABLE, since it speaks HTTP rather than loading anything out of the submodule)ldap_page.js(ldap.htmlthrough its own buttons, covering the five things that only exist in a browser. The DNs the shorthand panes BUILD from four fields — nothing in the protocol notices a wrong composition, the operation simply happens somewhere else and succeeds, so the page previews both and the test reads the preview as well as the outcome. That membership is a modify on the group, asserted against the request the page built and not only against the result. That the four search presets FILL THE FIELDS rather than running a hidden query — the filter is the thing worth reading, especially the fourth, since there is no attribute on a user that lists their groups and the question is answered from the other end. That a refusal is shown as a RESULT and logged as a Failure, while a row that staysSentmeans the api never answered — the two states people most often confuse, and the Operations History is asserted to hold noSentrow at the end. That the Search pane pages its results in the BROWSER. It creates the entries it pages rather than looking for them — 23 in a container of its own plus the container, which is three pages of ten and a short last one, because this directory holds whatever the run before it left behind and a section that pages only when it happens to find enough entries is a section that reports OK for doing nothing. Two of the four assertions there are about things the protocol cannot see and both are mutation-tested: that the rows are numbered within the ANSWER, so page 3 starts at 21 rather than at 1 (numbering that restarts loses the reader’s place in a result set of several hundred), and that turning a page adds no Operations History row — a pager that quietly re-ran the search would give a different answer under the same page number and would do it to somebody’s production directory once per click. It also holdsAllopen as an option, since that is the pre-pagination behaviour and the right one for “is X in here at all”, and it is whatresultDns()now selects before reading the table: every older assertion in this file about what a search returned would otherwise have quietly become an assertion about what came back on page one. Plus the one credential rule nothing else can see: the password is never written tolocalStoragewhile every other field is, and it is redacted from the Exchange pane with only its length kept. It also repeatscheckStylesheetsLoaded()’sldap-check itself, becausenavigation.jscannot walk to this card — it is one of the three the static deployments disable. Needs the client, the api and the mock’s directory; gated onLDAP_AVAILABLE)scim_engine.js(the SCIM workflow’s three engines, in node, with no server, no browser and no page.scim_client.jscomposes every request and reads every answer,scim_scenarios.jsplans and judges, andapi/scim_proxy.jsdecides what the backend call path will forward — none of the three has a DOM or a socket, which is what this file exists to take advantage of. Two failures on this workflow are invisible to any test that only reads statuses off a live server, and both are here: a double-encodedid(the SCIM id is an LDAP DN, so%25anywhere in a path segment produces a 404 that reads exactly like a deleted user) and a wrong Digest hash (a 401 that reads exactly like a wrong password). RFC 7644’s twenty-two endpoints and RFC 7643 section 4.1’s complete attribute list are written out independently of the catalogues under test, because a list derived from the implementation agrees with it by construction and can notice nothing — so an endpoint nobody implemented and an optional attribute the generator never emits both fail here by name. The Digest section computes all three registered algorithms and their-sessvariants and compares each against a vector produced with node’s owncrypto, which is what the mock uses: the client’s hashes are@noble/hashesand node-forge, completely different implementations, and neither is Web Crypto — which has neither MD5 nor SHA-512/256, so a client that reached forcrypto.subtlewould report every MD5-only server as bad credentials. It also asserts the three-challenges-in-one-header case the mock actually sends (a naive split on,sees fifteen commas and destroys it) and that the STRONGEST offered algorithm is chosen, since the conventional ordering puts the weakest last and “the last challenge parsed” reliably means MD5. HOBA’s blob is checked to be length-prefixed rather than delimited — a dot-joined version is the right size and shape and verifies against nothing — and its origin to carry an explicit port even when it is the default, becauselocation.originomits it and a server reconstructing it fromHostdoes not. The scenario section asserts that every step carries an expectation (one with none is judged “no expectation, therefore fine”, a silent pass), that every reference names a step that runs EARLIER and that every referenced step actually captures, that N users means N distinct userNames, and — the one that matters most — thatjudge()calls a 409 on a duplicate a PASS and a 201 a FAILURE, which is the property the negative scenarios rest on and is exactly backwards in a runner that records what came back. Finally every refusalPOST /scimcan produce, with nothing on the other end, so a rule that stopped being enforced fails naming the rule rather than timing out against a host. Needs no service at all and is never gated — it runs on the static targets too, and it is first of the three SCIM jobs deliberately, because a broken request builder makes the other two fail in ways that look like a broken server)scim_protocol.js(every SCIM endpoint through the debugger’s api at the mock STS, and then read back out of the DIRECTORY the mock wrote to. That second read is the whole reason this file is long. A SCIM server answering 201 has said only that it accepted the request; what it STORED is a different question, and against this mock it is answerable exactly, because the mock has no store of its own — a SCIM create writes an entry in its embedded LDAP directory, soPOST /scim/v2/UsersandPOST /ldap/searchreach the same object. Every attribute the test sends is therefore checked against the LDAP attribute the mock’s ownscim_map.jssays it lands in, and that mapping table is transcribed rather than imported for the reason above. What it catches is the most common real defect in a provisioning integration and one a status-only test cannot see: a field accepted and silently dropped —titlesent, 201 returned, nothing in the directory, and the two look identical at the moment of the create. It also checks the$-separatedpostalAddress(SCIM’sformattedis a display string with newlines and RFC 4517 3.3.28 is not), the CAPITALISED LDAP boolean, that a PATCHaddon a multi-valued attribute APPENDS rather than replacing the array, that a value-filter path removes exactly the value it names rather than emptying the attribute, 1-indexed paging with non-overlapping pages,count=0returning a total and no resources, all fourteen filter forms against a user known to match — each one enumerated across EVERY page rather than judged on the first, since ten of the fourteen (pr, the fourmeta.createdcomparisons,not, the value filter, andne) cannot be scoped to this run and match the whole directory: asking forcount=50and looking for this run’s user in it was a bet on a shared directory that grows all run, and it started losing on 2026-09-02 withmatched 50 user(s)and a message blaming the operator. The negative (ne) was the half that had quietly stopped asserting — absence from page one is not absence. ANDing the run’s prefix into each filter would bound the set and destroy the test, because the operator under test could then be dropped silently and theswhalf would still return the subject, so the filter goes to the server exactly as written and the walk leans on thesortByand 1-indexed-paging checks made a few lines above it — and that a group created INSIDE a bulk really resolves itsbulkId:member references — the feature that makes a bulk more than a loop, and without which that scenario is three creates and an empty group. The three-outcome rule is asserted on every negative: a refusal by the api is a 400, an unreachable server is a 502, and a SCIM error from the far end is a 200 carrying its status andscimType, because a 409uniquenessand the 501 on/Meare the server ANSWERING and are the most interesting thing it ever says. Its authentication section exercises all six schemes RFC 7644 section 2 names, once each, plus the negative that proves each check is really running — a scheme that accepted everything would pass a positive-only test perfectly — and the scope policy, including that a bulk of nothing but a DELETE is refused on a read-only credential, since section 3.7 defines no read operation inside one and a server deciding per-operation would let that envelope through. It is deliberately NOT a cross-product with the endpoints: forty-two runs of the same header parser test nothing the first did not. Two of the six skip with a reason rather than passing vacuously — a session cookie needs a browser that has signed in, and a client certificate would be the api’s OWN, a different identity and a misleading one. The deprovisioning at the end is itself the most important test in a provisioning suite: every delete is followed by a read expecting 404, and the directory is swept for leftovers, because a delete that answers 204 and leaves the resource behind is a path that has never actually worked.SCIM_BASE_URLis the API’s view of the mock rather than this test’s — the same distinctionLDAP_URLdraws, and on the containerized stack a different answer. Needs the api and the mock STS; no browser; gated onLDAP_AVAILABLE, since it drivesPOST /scimand a static site has no api — and it skips itself, naming the submodule, when the mock has no/scim/v2routes)scim_page.js(scim.htmlthrough its own controls, covering the five things that only exist in a browser — it deliberately does NOT re-drive every endpoint, because the two files above already do. The browser call path itself, which is the DEFAULT here and the only one the static deployments have, and which no other job exercises: a page that worked only through the api would pass every other test in this suite and be dead on the hosted site. The two credentials computed with Web Crypto — a DPoP proof signed over the exact method and URL and carrying the token’s hash, and an RSA HOBA key generated in the page — neither of which exists inscim_protocol.js, which signs with node’s crypto, a different implementation; this is also where the secure-context hazard would first appear, so both sections skip naming it whenwindow.isSecureContextis false. That the two browser-only schemes LOCK the call path, since the api has no cookie jar and would present its own certificate, so such a call would go out with no credential at all and the 401 would read as the server’s fault — and that the lock comes back OFF for a header-carried scheme, or the backend path is dead for the rest of the session once somebody picks a cookie. The scenario runner actually running: that the steps go in order, that an id captured by a create reaches the membership PATCH seven steps later (a reference that did not resolve makes that step SKIPPED rather than failed, which is why the check reads the verdict rather than the count), that the progress table fills, and that the negatives scenario finishes GREEN — a 409 on a duplicate recorded as “as planned”. And what the page remembers: the password never reacheslocalStorageunder any setting, the access token reaches it only withscim_save_tokenticked, and CLEARING that box purges what was already written — asserted separately, because an opt-out that leaves yesterday’s credential in storage is not an opt-out, which is why the purge lives insaveState()rather than only in the change handler. Plus thescim-stylesheet checknavigation.jsdoes make for this card, repeated here with the geometry: a SCIM id is a percent-encoded DN, a long unbroken string with no space in it, and bootstrap’scode { white-space: nowrap }plus an auto-layout table is exactly how one pushes a pane past the edge of the viewport. Not gated onLDAP_AVAILABLE, unlike its two siblings and unlikeldap_page.js: the browser call path is the one a static target has, so this job runs there and only its own backend-path section skips.SCIM_BROWSER_URLis the BROWSER’s view of the mock — a third answer again, and the one that has cost this suite a run before)ssf_engine.js(the Shared Signals workflow’s engines, in node, with no transmitter, no browser and no page.ssf_client.jsis the PIPE — RFC 9493’s eight subject identifier formats, SSF’s complex subject, the RFC 8417 SET envelope, stream configurations, both deliveries —ssf_events.jsis the VOCABULARY,ssf_history.jsthe two histories, andapi/ssf_proxy.jsandapi/ssf_receiver.jsdecide what the backend call path forwards and what the push inbox accepts. The defects this protocol actually produces are never crashes, which is the whole argument for the file: a subject identifier with an extra member, which RFC 9493 section 3 makes a MUST-reject because a receiver cannot tell whether the member NARROWS the subject — and which looks perfectly fine in a log; anexpon a SET, which section 4.1.4 forbids because a SET records that something HAPPENED and a fact does not stop being true;events_requestedread back asevents_delivered, so a receiver waits for event types nothing will send and its ABSENCE from that list is the only notice SSF gives; a delivery method speltpushrather thanurn:ietf:rfc:8935; asubclaim where SSF putssub_id, so a client silently reads nothing. Every one of those produces a workflow that works perfectly against itself. It exercises every valid combination: all eight formats and their specimens, every required member removed in turn, every value rule, the nesting ban of section 3.2.8, all six complex-subject members alone and together, and — the discriminator that catches people — that anopaquesubject whose id happens to be speltuseris still SIMPLE, because the thing that tells a complex subject from a simple one is the ABSENCE offormatand not the presence of a member name. It signs and verifies a SET with one algorithm from every familyjws.jsoffers, post-quantum and composite included, which is where those paths through this workflow’s own envelope are exercised at all — and it asserts that the header carries the JOSEalgrather than jws.js’s SELECTION id, since RFC 8037 registers onealgfor both Edwards curves and a header sayingEdDSA-Ed25519names an algorithm the registry does not have. Finally every refusal the api’s proxy makes and every bound on its push inbox, with no listener at all. Needs no service and is never gated; first of the four SSF jobs deliberately, because a broken engine makes the other three fail in ways that look like a broken transmitter)ssf_protocol.js(the Shared Signals workflow against a real transmitter, over HTTP, with no browser — and its value is that the two implementations are deliberately SEPARATE. The mock has its own RFC 9493 grammar and this workflow has its own, written independently: if both ends read one implementation, a misunderstanding they share is one neither can see and the round trip passes while interoperating with nothing. So this drives ONE reading of that specification against ANOTHER over the wire — all eight formats, the complex subject with every member, and three refusals the TRANSMITTER makes. It is almost entirely negatives. The stream lifecycle covers the distinction every REST API gets wrong in the same direction: PUT resets what was omitted and PATCH does not, and a PUT behaving like a PATCH would let a receiver believe it had clearedevents_requestedwhen it had not. The status section is the one that could not be written any other way: an event asked for while a stream is PAUSED must still be there after the resume, and one queued before a DISABLE must not survive it — that is the difference between “I was not listening” and “it did not happen”, and it is the whole reason a receiver has a pause. It hosts an RFC 8935 listener of its own, four lines of node, which is what makes push delivery testable end to end at all — a browser cannot, andSSF_RECEIVER_HOSTis the name the TRANSMITTER has to reach it by, a fourth answer to a question this suite has got wrong before. It also reverses the roles and pushes a SET it signed itself, once with ES256 and once with ML-DSA-44, and drives both of the mock’s deliberate defects:ssf.legacySubClaimandssf.breakSetSignature. Every setting it changes is restored in afinally, and it holds thests-ssfjob lock for that reason. Needs a transmitter; no api and no browser; skips itself with a reason when there is none)api_ssf.js(the api’s Shared Signals surface over HTTP, and it exists for four things no module test can see. The first would be silent: the body parser forapplication/secevent+jwt. NeitherbodyParser.json()norbodyParser.urlencoded()touches that media type, so without the text parserapi/server.jsinstalls for itreq.bodyon a push is an empty object and every delivery is reported as an empty body — a failure that reads as a transmitter sending nothing. The second is the three outcomes as HTTP STATUSES rather than return values, and the third of those is the one that matters: a refusal by the api is a 400, a network failure is a 502, and an SSF error from the transmitter is a 200 carrying its status and its{err, description}— because a 403 naming a scope and a 400 naming the member of a subject identifier RFC 9493 does not define are the most useful things this workflow can show. The third is thatapi/ssrf_guard.js, installed once on the shared axios instance and deliberately NOT re-implemented in the SSF proxy, still covers this endpoint. The fourth is the push receiver a browser cannot be: created, pushed to with the right media type and with the WRONG one — which is accepted and REPORTED, because a receiver that dispatches on the type drops such a token with no error anybody sees and refusing would hide the finding — drained through its cursor, cleared and deleted. It also asserts the one place this proxy differs from the SCIM one: a body on a DELETE is allowed, since SSF’s stream management API is one path with five methods and a delete names its stream in a JSON body. Needs the api; no transmitter, no browser; gated onLDAP_AVAILABLEand skips itself when there is no api)ssf_page.js(ssf.htmlthrough its own controls, covering what only exists in a browser. That the bundle ran at all — a page whose<script>404s renders perfectly and does nothing, and every inline handler on it is a ReferenceError; the three pickers are built byonload()from the modules, so the check is that they have nine subject formats, two event types and more than twenty algorithms rather than that they exist. ThecallPathrow, which is the one control this page disables on a deployment with no api, and it asserts the state the page reached AGREES with whether the api actually answers — a build that offers a path with nothing behind it produces a fetch failure on every call, which reads as a broken transmitter. The verification event end to end: asked for, and the page has to say the EVENT IS A SEPARATE EXCHANGE and has not been seen yet, because a receiver that read the 204 as the event would believe the pipe works when nothing had crossed it. Signing in the JAVASCRIPT engine, not Web Crypto:crypto.subtledoes not exist at all on the containerized suite’shttp://client:3000origin, and this section is what would fail first if the page ever started depending on it. Both histories, and where each lives — the token history insessionStoragebecause a token is a credential, the message history inlocalStoragebecause a Security Event Token is EVIDENCE rather than a credential and evidence has to survive a navigation — plus that the access token, the ID Token and the transmitting private key reach neither. And the extended OAuth2 / OIDC hand-off, which no other job exercises: the slot is filled the wayoauth2_oidc_2.jsfills it and the page reloaded, and what is asserted is that the WHOLE TOKEN SET crossed — because an access token this project’s mock issues is opaque to a client, so without the ID Token the page cannot say who is signed in — and that the slot is then EMPTY, since it is one-shot. Plus thessf-stylesheet check and a clean console. Not gated onLDAP_AVAILABLE, forscim_page.js’s reason: the browser call path is the one a static target has. Shares thests-ssfjob lock withssf_protocol.js)caep_engine.js(the CAEP vocabulary and the session model, in node, with no transmitter and no browser. CAEP’s eight event types are rows inclient/src/ssf_events.js— which is what that file’s header promised while it had two — andclient/src/caep_session.jsis what those rows are ABOUT: a session, the state CAEP believes it is in, and what has been said concerning it. The eight URIs are written out as string literals from the specification rather than built fromCAEP_PREFIX, which is the point of that section: every row in the catalogue is built from that constant, so a typo in it would produce eight consistently wrong URIs that agree with each other perfectly and with nobody else — and this protocol has no unknown event type error, so a receiver would ignore them all in silence. Then every required member and every closed enumeration; the four claims CAEP section 2 gives them all, accepted on all eight and all optional; and the three checks that catch a document nothing would report:reason_adminas a STRING rather than an object keyed by a BCP 47 tag, which is the commonest mistake in the profile and has NO SYMPTOM because a receiver indexing by language reads nothing from one;event_timestampas the string"1757000000", parsed by everything and compared numerically by nobody; andamras a bare string, refused rather than wrapped because wrapping would hide a sender that can only ever say one. The three OPEN enumerations —credential_type,namespace,principal— warn rather than refuse, because CAEP lets two parties agree their own and a debugger that could not build a vendor’s is no use; the case trap goes with them, sinceprincipalisSESSIONupper case and the complex subject’s member issessionlower. Then the subject throughssf_client.js’s own RFC 9493 grammar — including that a subject naming only the PERSON is perfectly valid and is the most dangerous thing this page can send, because it asks a receiver to end every session they have. Finally the state machine: every transition, its ONE hard refusal (asession-presentedabout a session already revoked, which is a transmitter contradicting itself), the missed-event warnings thatprevious_statusandprevious_levelexist for and that are invisible from either event on its own, and the counters against the ring. Needs no service and is never gated; first of the three CAEP jobs for the reasonssf_engine.jsis first of the four)caep_protocol.js(CAEP against a real transmitter, and the only test in this suite whose subject is something the far end DECIDED TO DO. Every other job drives a request and reads the answer; this one signs somebody in over HTTP and then waits for a Security Event Token nobody asked for. It drives the mock’s sign-in screen by hand —GET /oauth2/authorizefor the pending id, thenPOST /authn/login— and stops at the session, becausestartSession()is where the event is emitted and going further would mean answering the consent screen, whose setting this job holds no lock on. What it asserts: that all eight types are agreed on a stream and come back inevents_delivered; that the mock’s independently written reading of CAEP section 3 requires exactly the members this build does, which is the argumentssf_protocol.jsmakes about RFC 9493 applied to a vocabulary; that a sign-in, a single sign-on and a sign-out each put an event on the stream, each carrying SSF’s COMPLEX subject insub_idand not insub, with noexp; thatcaep.autoEmitoff really does stop it and the session is still tracked with a count of zero, which is what makes the setting a setting rather than a comment; that a stream naming the PERSON covers an event about a SESSION of theirs, without which CAEP delivers nothing at all and does so silently; every one of the eight emitted by hand throughPOST /admin-api/caep/emit; thatcaep.omitEventTimestampproduces an event with none which is still VALID; and that the register outlives the session it describes, which is the only reason a revoked session is visible anywhere. Every setting it changes is restored in afinally, and it holds thests-ssfjob lock — for a reason the SSF three did not have, since a sign-out anywhere in this suite now puts an event on every stream that asked for one. Needs a transmitter; no api and no browser; skips itself with a reason when there is none)caep_page.js(the CAEP half ofssf.html, and the profile switch that reconfigures the workflow. Its failure mode is not an error: a stream agreed for the wrong vocabulary looks perfectly healthy and delivers nothing, so the assertion is that BOTH event lists narrow together — the Transmit menu and the stream’sevents_requestedcheckboxes — and that every other pane is STILL THERE, because CAEP events travel on the same streams through the same deliveries and hiding the Stream pane would be hiding the thing they travel on. RISC is drawn saying it is not implemented rather than omitted, because a reader who cannot tell this tool does not do RISC from I have not found it yet is being told the wrong thing by an omission. All eight supported grants are mapped onto the THREE SHAPES they produce — an ID Token with asid, one without, and no ID Token at all — and each shape is driven through the pane, because the third is not an error case: client credentials and resource owner password are two of the eight and neither issues one, and a page that showed an authenticated user it could not name would be the worst kind of wrong. Two of them are then driven FOR REAL through the hand-off, chosen because neither goes near a browser sign-in or a consent screen; whether the other six redeem isoauth2_*.jsandoidc_flows.js’s subject. Then every one of the eight events built, signed in the JavaScript engine and pushed —crypto.subtledoes not exist on the containerized suite’s origin — with the state and the counters following them, the model’s hard refusal on the page, and the reset, whose one failure mode is SILENCE: it has to NAME the stream it asked the transmitter to delete, because a reset that quietly failed leaves somebody debugging yesterday’s stream while believing they started over. Plus thessf-stylesheet check and a clean console. It found a product bug no other kind of test could: the pane redrew its eight buttons on every field change, a field’schangefires on BLUR, and that landed between a click’s mousedown and its mouseup — so the first press after typing did nothing, silently, and the second worked. Shares thests-ssfjob lock)pki_x509.js(certificate authoring,client/src/x509.js, checked against OpenSSL rather than against itself. The whole cross product the PKI page offers — every signature algorithm a key family can produce, against every subject key algorithm — is ~240 certificates, plus every X.509v3 extension one at a time and then all of them at once, all 14 profiles, and a four-deep root/intermediate/issuing/leaf chain. The reason it asserts by askingopenssl verifyandopenssl x509 -textrather than by reading its own output back is that every defect it was written for produces bytes that parse perfectly: pkijs builds a Name as one multi-valued RDN (a different name, which chains to nothing, and OpenSSL’s only tell is a+); pkijs wraps an otherName in a second[0], which-textprints happily andverifyrefuses withossl_x509v3_cache_extensions:reason(158), naming no extension, no name and no field; the hash a signing key is IMPORTED with is the one pkijs writes intosignatureAlgorithm, so importing at SHA-256 and signing at SHA-512 disagrees with itself; and a 2050+ date encoded as UTCTime reads as 1950. The sharpest assertions are the ones where OpenSSL must refuse something — a name outside a permitted subtree, a chain one CA deeper thanpathLenConstraint, a certificate carrying a critical extension nobody can understand — because an extension encoded so that it constrains nothing looks identical in every dump. Node only, no browser and no services, so it never skips; it needsopensslon the PATH and says so rather than skipping when it is absent. Seedocs/pki.md)pki_key_formats.js(key generation and every keystore format,client/src/key_material.js, read back by OpenSSL: 7 key algorithms × PEM / DER / JWK set / PKCS#12 × with and without a password, 49 files. This module was the bottom third ofjwt_tools.jsand was exercised only through that page’s Download button, which can see a status line and not a file — extracting it for the PKI page is what made the matrix testable, becauseexportKeyPair()now RETURNS the files. It asserts that an encrypted export is actually encrypted (the wrong password must fail), that a PKCS#12 carries the private key AND its certificate chain rather than a bare key, that a JWK survives a PEM→JWK→PEM round trip withkey_ops/extstripped, and that the refusals name the right mistake. It also asserts the one refusal that was removed: PKCS#12 for Ed25519, which had been attributed to the format and was really pkijs being unable to import an Ed25519 public key — the certificate isx509.js’s job now, so the format works. Node only, never skipped)pki_pqc_x509.js(the post-quantum half of the PKI workflow —client/src/pqc_x509.jsand the post-quantum rows ofx509.jsandkey_material.js— checked against a DIFFERENT OpenSSL. It is a second file rather than more rows inpki_x509.jsfor a reason that is about the oracle rather than about size: theopensslBINARY is whatever the base image or the developer’s machine ships —ubuntu:latestcarries 3.5 today and an Ubuntu 22.04 host carries 3.0, which has no ML-DSA, SLH-DSA or ML-KEM at all and reports an ML-DSA certificate asX509_PUBKEY_get0:decode error, a statement about OpenSSL rather than about the certificate — while node’s OpenSSL moves with the NODE version, which every image here pins at 24.16 (OpenSSL 3.5.6).tests/openssl35.jsis that oracle. It covers: 34 algorithm identifiers against a second, hand-written transcription of RFC 9881, RFC 9909, RFC 9935 and draft-ietf-lamps-pq-composite-sigs-19, so agreeing with the module means agreeing with two independent readings; every key’s SubjectPublicKeyInfo and PKCS#8 read back by OpenSSL, including all three arms of the private-key CHOICE (the RFC recommendsseed, OpenSSL writesboth, and a build that can only produce or consume one of them interoperates with half the world); pure ML-DSA and SLH-DSA certificates whose signatures OpenSSL verifies; the sixteen composite algorithms with each half tampered in turn, because a verifier that checks only the post-quantum half passes every other assertion in the file; 126 links of chains that mix classical and post-quantum issuers, which is the shape an organisation actually has during a migration; the X.509 (2019) hybrid extensions, verified here AND handed to the 3.0 binary, whose acceptance of them is the entire point of putting the second key in an extension; the preTBSCertificate’s two removals, asserted as the absence of two specific OIDs; post-quantum PKCS#10 requests; and the refusals — a self-signed ML-KEM certificate (a KEM signs nothing), an ML-DSA-44 signature asked of an ML-DSA-65 key, and signing from a key written in the seedless arm. The six slow SLH-DSA parameter sets are covered in the direction that is free — OpenSSL signs in C, this build verifies — because SLH-DSA-SHAKE-256s takes 18 seconds for one signature in JavaScript;PQC_SLOW=1issues certificates with them too. Node only, never skipped, except the OpenSSL cross-checks when node is older than 24)api_tls_probe.js(the TLS / mutual-TLS probe behindPOST /tls/connect,api/tls_probe.js. Same accounting as the Kerberos relay:tls.connectis a raw socket, soapi/ssrf_guard.js’s axios installation never sees it, and this is a third enforcement of the same policy — checked for both the literal and the resolves-to-a-literal case. The port allowlist is doing more work here than anywhere else, because there is no payload shape to bound this endpoint with: a ClientHello sent to port 22 is a well-formed ClientHello. The assertion that earns its keep most ismutualAuthIsMeasuredRatherThanAssumed(): a completed handshake is not an accepted client certificate. Under TLS 1.3 the client is finished before the server has said anything about the certificate, and the refusal arrives afterwards either as a post-handshake alert or as a bare hang-up (node’s own TLS server does the latter), so an implementation resolving onsecureConnectanswers “client authentication not required” for every TLS 1.3 server on earth — and answersrequiredwhere the truth isrequired-and-rejected, which is the case an operator hits most. All five verdicts are exercised against throwaway listeners built with this project’s own certificate modules. Also the three deadlines, including the name lookup’s own (injected resolver that never calls back), the connect-versus-handshake split — a server that connects and then says nothing must survive to the call budget — and that every path settles, since aiming this at a host that may not be there is the point of it. Node only, never skipped)-
pki_page.js(the PKI page,client/public/pki.html: the Root → Intermediate → Issuing → leaf hierarchy built entirely through the form with a different key and signature algorithm at every level, the store surviving a reload, the list-field syntax the extension editor is made of, and the TLS test end to end through the api. It exists because the certificates themselves are already checked in node — what it catches is what lives between the form and the modules: a field read under one id and written under another (a whole extension silently absent from every certificate, with nothing on screen to say so), a store that does not survive F5, and the private-key opt-out, which fails silently in the reassuring direction and is therefore read out oflocalStorageexactly askeypair_storage_optout.jsdoes for the other four protocols. The opt-out here governs the private half only — the certificates are public documents and stay, which is what leaves the trust anchors usable — and a CA whose key was purged must vanish from the “Signed by” dropdown, because offering one produces a Web Crypto error two clicks later naming neither the CA nor the missing key. It also asserts the one thing this page’s design forbids: no radio button in the TLS pane, since a browser cannot present a chosen client certificate, cannot be given a truststore and cannot read a handshake. Since 2026-08-18 it also measures the page’s layout, which is the only part of this file that is not about correctness and the only part that undoes itself silently: the three configuration panes are one (pane_config) and the pane list is asserted by name, because a fourth pane looks like tidiness in a diff; the headings the lost legends became aredivs with no control to point at, so nothing else notices when one goes; that pane’s three columns (key paircertificate subject DN) are checked for their tops as well as their heights, since a grid row is as tall as its tallest item — a block moved between columns becomes dead space rather than a shorter page, and a .pki-colsminimum raised past 410px silently wraps the third column onto a row of its own and makes the pane taller than the two-column version it replaced; the two rows the middle column’s height turns on — the key algorithm, the JWK toggle, Generate Key Pair & Issue Certificate and its reuse checkbox on one; the keystore format, the password, both checkboxes and Download on the other — must each be one flex line, asserted as one shared bottom edge among their controls rather than as a height, because between 1330px and 1530px the row meets a width it cannot by wrapping a checkbox LABEL and stays one row, while a control that will not shrink (a longer button caption, a.pki-narrowwidth raised past what it holds) moves onto a line of its own and puts the key-pair column ~70px below the two beside it with nothing else on the page to say so; the twenty-two extension cards flow in three columns by way ofcolumns: 380px 3, which is a MAXIMUM — one card that stops fitting costs a whole column, ~400px of page whose only symptom is that you scroll further, so the column count is read from where the cards actually landed at 1366px; the whole page must fit a 2700px budget, which is the 2196px it measures plus slack for font metrics; the page must not scroll sideways (.pki-colwithoutmin-width: 0is overflowed by one 64-character line of base64 in a PEM textarea, and awhite-space: nowrapon a sentence-long.pki-flaghangs it out of a half-width column); no control may be drawn OUTSIDE the column it is in, which is the overflow the page cannot show you — the grid still fits, so there is no scrollbar, nothing is clipped and the height does not move, and a fixed-width control in aflex: 1 1 0field simply overlaps its neighbour (saml_common.csssizes text, password, select and textarea atwidth: 100%and does not nameinput[type="number"], which left bootstrap’s 220px on Validity (years), 49px inside the Key Pair column and over its Keystore Format select, and on the TLS pane’s Port; both are fixed incss/pki.cssand the check is over every control, since any type the shared sheet does not name arrives the same way); and every<details class="pki-more">must still hold its paragraph — prose there is folded rather than cut — while holding no form control at all, since a checkbox behind a summary is a setting nobody finds. It also asserts the serial number, which stopped being an optional empty field on 2026-08-18: the page shows a random 128-bit one on load, persists it like every other.storedfield, signs the value that was on screen, and puts a NEW one there after every issue — the rotation being the part that fails silently, since a serial left in place gives the next certificate from the same CA the one (issuer, serial) pair a CRL entry, an OCSP request and every cache keyed on it cannot tell apart, while the store shows two rows differing everywhere else. A serial typed by hand is signed as typed. It also drives the View certificate details hand-off, which is the one thing in this file that leaves the PKI page: the button writes the PEM tolocalStorage['saml_cert_view']and openssaml_cert.htmlin a second tab, so the test switches to that tab and reads the table there — once per key algorithm, EC first. That page parsed with node-forge until 2026-08-18, and node-forge reads the SubjectPublicKeyInfo eagerly and knows one algorithm, so every EC and every Ed25519 certificate this page issues died there onParse error: Cannot read public key. OID is not RSA.— a message about a key, from a page that had not reached the subject, in a tab neither module’s own tests open (pki_x509.jshas no browser and this file had never left the PKI page). It also checks the return link points back atpki.html, since that page labels its way back from?from=and a caller missing from the map strands the reader. It also asserts, since 2026-08-18, the three things that fill the form before anybody types: the rest of the subject DN (O/OU/L/ST/C fromx509.DEFAULT_DN, compared against the module rather than a copied list — fill-only, since they do not vary by profile, and never overwritten by the reload that is the path which would lose them); the subjectAltName the two serverAuth profiles fill withdns:+ their own CN, ticked as well as filled, and taken away again when a Root CA is chosen after a TLS Server, because for a TLS server the CN is not what a client checks and a certificate with the CN alone is refused by every current browser with nothing on the page to say so; and a tooltip on every one of the 131 controls — on itself, on itslabel[for], or on a<label>around it — which stopped being cosmetic when the standing notes were moved into titles to take the page from ~2600px to 2196px, since a field with no tooltip now has no explanation anywhere (the three that failed it first were labels carrying atitleand nofor, which the browser associates with nothing). It asserts that Not Before / Not After aredatetime-localpickers and that a2031-03-04T05:06:07Zwritten by the older build still arrives: such an input refuses anything butYYYY-MM-DDTHH:MMby silently emptying itself, so without the conversion inrestoreState()the upgrade discards stored validity dates, and a picked date has to reach the encoder as the instant it names. It also asserts the subject CN each profile fills in: every profile leaves a name in the field, the three CA profiles leave three different ones (they are issued one after another into the same store and the chain view is read by subject), and a CN typed by hand survives both a profile change and the reload that re-runsonProfileChange()over a fieldrestoreState()has just refilled. Needs the client; the TLS section skips without an api) pki_mutual_tls.js(the PKI page against a server that answers back, and the halfpki_page.jsdeliberately cannot cover: that test points the TLS pane at the client’s own plain-HTTP port, so a failed handshake with a real alert proves the whole round trip and needs no TLS service — and therefore proves nothing about the certificate, since nothing ever accepted one. This one issues a Root, an Issuing CA and atls-clientleaf entirely through the form, presents the leaf with its chain to the mock STS’s main HTTPS port through the api, and asserts on the server’s own account of the connection, fetched over that connection — which is the only place it can be said at all whether the far end ACCEPTED the certificate, since under TLS 1.3 the client has not been told by the time its own handshake completes. It runs the SAME connection twice, before and after POSTing the Root to the mock’s/tls/trust, and the far end’s verdict is the whole difference between them: NOT verified and signing nobody in, then VERIFIED and starting a session, on a certificate identified by its RFC 8705 thumbprint — the SHA-256 of the DER this browser issued a minute earlier, which is what says the report is about that certificate and not merely one with the same name. It was pointed at two listeners of the mock’s own — 8443, which asked for a certificate and never refused one, and 9443, which required one — until both were deleted there on 2026-09-16, and that cost it three assertions it no longer makes: the chain the server BUILT out of what was sent, the far end’s reading of the client’s public key (which is how the post-quantum case proved no classical fallback, and which the thumbprint replaces exactly), and arequiredmutual-auth verdict. The verdict it measures now isnot-required, still measured by a second anonymous connection rather than assumed, because refusing at the handshake is a property of a socket and the one that remains carries every other protocol that service speaks. Seedocs/pki.md. It empties that truststore again on the way out, since it is process state on a service the rest of the suite shares. Gated onSTS_TLS_URL, the mock’s plain HTTP base, which is a separate variable fromWSTRUST_STS_URLfor the reasonWSFED_STS_METADATA_URLis — that one may point at a real Apache CXF STS with no such endpoint — and it must be reachable by the api as well as by the test, since the api is what opens the socket. Skips without it, without an api, and against a mock older than the endpoint)api_ssrf_guard.js(the api’s outbound address policy,api/ssrf_guard.js: which addresses the default list refuses, the range notations the configuration accepts, both enforcement layers — the URL pre-flight and the agent — the off switch, and ALLOW-LIST mode (allowedAddressRanges: on regardless of the switch, loopback inside it reachable through the agent, everything outside refused with a message naming the list, an unusable list failing closed, and all four raw-socket relays asking the sameenabled/blockedRangeFor()pair). No browser and no services, so it never skips. Mutation-tested: removing the agent’s literal-address check, the IPv4-mapped reduction, or the refusal of bare single addresses each makes it fail)embedded_deployment.js(this repository’s half of the debugger embedded in the mock STS,embedded/CLAUDE.md:common/tls_listener.json a unix socket — plain HTTP despitehttps: true, mode 0600, no certificate, a stale socket replaced and a regular file refused, and thedebugger-api-listeningIPC message to a forking parent;client/src/env/embedded.jsevaluated with a fakewindow;api/env/embedded.jsrequired with theDEBUGGER_*environment, including an allow-list that fails closed on a value that is not a JSON array; andclient/build.jsrefusing an unknownDEPLOYMENT. No browser and no services, so it never skips)dpop_vectors.js(DPoP’s arithmetic,client/src/dpop.js: the RFC 7638 JWK Thumbprint that becomescnf.jkt,htunormalization, theathhash and the proof’s shape. Checked against the RFCs’ own published vectors — RFC 9449’s worked example prints an EC key and thejktof the token bound to it, RFC 7638 section 3.1 does the same for RSA — plus the three silent ways a thumbprint goes wrong: an extra member, the wrong member order, whitespace. Signatures are verified with node’s crypto, which shares no code with the Web Crypto path that made them. No browser and no services, so it never skips; it found a real API hazard on its first run,proof()throwing synchronously instead of rejecting)sts_dpop.js— Retired here on 2026-10-22: iya-sts’s own suite owns it (sts/tests/vendored/sts_dpop.js, alocaljob there), so this suite no longer runs a second copy against the mock.sts_jws_verification.js— moved to iya-sts’s own suite on 2026-10-23 (tests/vendored/sts_jws_verification.jsthere, now owned there): it tests what that service accepts, not this debugger.oidc_userinfo_protected_page.js(the UserInfo PAGE reading a protected response, in a browser — the halfsts_userinfo_protected.jscannot reach, since a response the engines can open and the page cannot render is an empty box that nothing in node sees. All four shapes (JSON, JWS, JWE, JWS-inside-JWE) and one algorithm per family, because the page implements none of them —jws.jsandjose_jwe.jsdo, and every algorithm is driven against those elsewhere; what is browser-specific is the wiring and the report. Asserts the distinctions that make the pane worth having: “decrypted” is never presented as “verified”, an encrypted-but-unsigned response says nothing proves who issued it, a nested JWS whose outer header omitsctyis a finding, andiss/aud/subare each checked by name. The console is judged BEFORE the negatives, not after — the negatives hand the page a missing key, a wrong audience and an edited payload on purpose and the page reports each throughlog.error, so reading the console afterwards would be asserting that a deliberate failure is silent. Needs the client and the STS)algorithm_panes.js(every option in the JWT Tools and Encryption menus exercised in a browser, not merely listed.algorithm_menus.jsproves an option EXISTS; this proves choosing it does something, and the two are not the same claim. It earned its place immediately: the JWT Tools signing menu had been widened to the engine’s twenty-seven algorithms and fourteen of them answered “No key was supplied for alg=…” when chosen, because that page’s key panes are PEM-based and a post-quantum key has no PEM form. The menu is back to the thirteen the page can perform andalgorithm_menus.jsnow measures it against the page’s ownSIGN_ALGSrather than the engine’s list. Also asserts nothing is disabled — the capability probe used to grey out the AES-192 options — and clears the output before each signature, becausesignJWT()leaves the previous token in place when it fails and a stale one reads as success. Needs only the client)-
sts_userinfo_protected.js(the signed and encrypted UserInfo response, OIDC Core section 5.3.2, against the mock over HTTP with no browser. It is a cross-implementation check and that is the whole point of it: the mock produces every shape with node’s OpenSSL (sts/common/crypto.js) and each one is opened with the DEBUGGER’s own engines —client/src/jws.jsandclient/src/jose_jwe.js, which are Web Crypto — so what it can see is the class of defect a round trip through either side alone never can: a CBC-HMAC content encryption key split the wrong way round (the MAC key is the FIRST half), an AL computed in bytes rather than bits, a tag taken as the whole HMAC rather than its first half, a Concat KDF that stops after one round — invisible until something asks for more than 32 bytes, which A192CBC-HS384 and A256CBC-HS512 do — and actymissing from the outer header of a Nested JWT. Each of those produces a document the side that made it reads back perfectly. It drives every algorithm the metadata ADVERTISES rather than a list of its own, so an algorithm advertised and not implemented fails here rather than in somebody’s client: currently 25 signing algorithms — every one the JWS registry defines, plus eleven post-quantum ones (ML-DSA, SLH-DSA and the six composite ML-DSA + traditional algorithms), whose mock-side implementation is written independently of the debugger’s so this stays a cross-check rather than a round trip — RS/PS against the RSA key, ES256/384/512 and EdDSA against the EC and Ed25519 keys the mock now generates beside it, ES256K on secp256k1, and HS against the client secret — and 36 alg/enc pairs. It also asserts every ECDSA signature is the **RS** concatenation of RFC 7518 section 3.4 and not the DER SEQUENCE node returns, by its exact length: about half of all ECDSA signatures need a leading zero stripped or a sign byte removed, so a converter that mishandles either passes an end-to-end check roughly half the time. Also asserts the four shapes (JSON / JWS / JWE / JWS-inside-JWE), that signing happens BEFORE encryption, that an omitted userinfo_encrypted_response_encmeans A128CBC-HS256 as the registration spec says, that a signed response carriesissand anaudnaming this client — the two members that are the entire reason to want one — and, as negatives, that an algorithm the server cannot perform and an encryption registration with no key are REFUSED rather than quietly downgraded to unprotected JSON, which a client has no way to notice. Needs only the STS) dpop_workflow.js(DPoP through the VC Issuance pages, which is what the other two cannot reach: that the wallet really sends the proofs, that the token really came back bound — asserted against the access token’s owncnf.jkt, recomputed in the browser from the displayed JWK rather than read off the pane — that Holder of Key really binds the credential to the DPoP key, and that the two checkboxes are honest about each other. Driven with the pre-authorized code grant, so it needs the STS and the client and no identity provider)-
oauth2_sts_endpoints.js— Retired here on 2026-10-22: iya-sts’s own suite owns it (sts/tests/vendored/oauth2_sts_endpoints.js, alocaljob there), so this suite no longer runs a second copy against the mock. -
webauthn_analyzer_page.js(the Analyzer page,webauthn_analyzer.html, driven against the same real ceremonies. It covers what the node test cannot: that the decoded values reach the screen — a pane left empty by a renamed element id decodes perfectly and shows nothing. Also asserts the layout bound with an RSA modulus loaded, that being the longest unbreakable string the page will ever hold, and that nonsense input is refused by name rather than in silence. Needs the client and nothing else: the page performs no ceremony, so no authenticator, no IdP, no network, and not even a secure context) -
webauthn_lab_page.js(the Lab page,webauthn.html, running REAL ceremonies against the WebDriver virtual authenticator — a CTAP2 authenticator inside the browser, so no hardware, no touch, no flake. Registration decoded end to end, an assertion verified against the key kept from it, the sign counter advancing across two assertions, and a removed credential reported asNotAllowedErrorwith its deliberate ambiguity explained rather than guessed at. Note what is deliberately absent: a UV-required ceremony against an authenticator that cannot verify is refused by the browser, so the relying party never receives a UV-clear assertion and that check cannot be exercised here — it lives inwebauthn_decode.js, where the material can be manufactured. Two hazards recorded in the file: the JS bindings’VirtualAuthenticatorOptionssetters returnundefinedso they cannot be chained, and the resulting failure readsWebAuthn is not supported on sites with TLS certificate errors, which sends you hunting through origins instead of at the line above) -
oauth2_config_pane_layout.js(the Configuration Parameters pane on both OAuth2/OIDC pages, measured at seven viewport widths. Bootstrap sizes every text input, select and textarea to a fixedwidth: 206px, which outranks thesize="60"several of those fields carry and takes no notice of the box it is in — so the same markup and the same stylesheet give a comfortable pane onoauth2_oidc_1.html(~854px, two flex columns) and a pane onoauth2_oidc_2.html(~419px, three flex columns) whose fields sit on the border and whose four booleanselects hang 25px past it, 136px at an 820px viewport. It asserts BOTH halves: the outcome — no control inside#config_fieldsetcrosses the pane’s content edge, tooltip bubbles excepted, since those are positioned popups meant to leave it — and the rule underneath, that each field’s width equals its own table cell’s rather than sitting at bootstrap’s 220px. The second is what makes a failure legible and is the one that fires on page 1, where the geometry alone never could: an 854px pane hides a 220px field perfectly well, and the next narrow pane is where it shows up. A third assertion holds the fix to its own boundary — checkboxes and radios stay intrinsic, because a selector broadened toinputwould stretch a radio across the pane and nobody would report that as a bug. The widths straddle both container steps inbootstrap-responsive.css(1170 / 940 / 724), since one width would have missed this exact defect at 1366; the range stops at 768 because below roughly 700 the three-column flex row itself stops being tenable — the pane is then narrower than one long label, which is a question about the row and not about these fields. Both assertions mutation-tested against the pre-fix stylesheet. Needs the client alone — no api, no IdP, no STS — and every field it measures is in the served HTML rather than drawn by the bundle, so it never skips and runs unchanged against a deployed static site) federation_sso.js(a federated sign-in, end to end, across two TRUST REALMS of the one mock STS — the debugger’s OAuth2/OIDC workflow standing in for an application calledwebapp-sso-test1, registered infederation-realm-1, which is an OpenID Provider to it and the SAML 2.0 service provider of a federation relationship withfederation-realm-2, where a name is actually typed. It replacesfederation-e2e/in the sts/ submodule, which built the same shape out of three containers — two instances of the mock and a web application written for it — because realms did not exist when that was written; a realm is a whole logical copy of that service on the same socket under a path prefix, so one process is now two identity services, and the debugger is already an application that has never heard of federation. What the move gave up is the front-channel / back-channel distinction, which needs two origins and two DNS names; what it bought is a job that runs in the ordinary suite, on every stack, in about four seconds. Everything is provisioned first, through/admin-api(never/admin, which is gated): the two realms, the application — withoauthRedirectUricarrying both the URI the fictional application would use and the debugger’s own callback, because the point of pre-registering is the mode that will one day check the list — and the relationship, whosefedSsoUrl,fedSigningCertificateandfedPeerare read out of realm 2’s own metadata, fetched by the service provider’s entityID so that the per-service-provider entityID it carries is the one the assertions will actually arrive with. What ties the application to the partner isappFederationRelationshipon the application entry, withappFederationAutoRedirectbeside it — two attributes added to the mock’s application schema for this, read by itsauthn.js, so the browser goes straight on to the partner instead of stopping at a sign-in screen with a button on it. The assertions are about the SHAPE of the flow and the STATE left behind rather than about a token arriving, since every interesting failure here produces one: realm 1’s own screen is never drawn and realm 2’s is, realm 2 offers no partner of its own (the two realms run one codebase and differ only in configuration), realm 2 has never heard of the application (the directory is per realm), the ID Token saysiss: …/realm/federation-realm-1and names realm 2 nowhere, the relationship counted exactly one sign-in with no error, and realm 1 has a directory entry for somebody whose password was never checked there. Two of them carry the weight. A forged, unsigned assertion naming the configured partner and claiming to beadministratoris refused 401 on the signature, recorded, and does not count as a sign-in — built to reach that check rather than the Status one in front of it, because a happy path proves nothing about a surface whose bugs are authentication bypasses. And the tie is mutation-tested in place: the last section clearsappFederationRelationship, runs the same authorization request, and requires it to stop at realm 1’s own sign-in screen — without which every assertion above would pass just as well against a mock that federated every sign-in for reasons of its own. It also compares the address the mock’s console advertises for the relationship against the one the flow actually uses as itsIssuerand assertion consumer service — two modules build those (admin-ui/admin.jsandfederation/federation_sp.js) and nothing else keeps them in step; it caught the detail page building its base as'http://' + req.get('host'), which meant no realm prefix (a URL that 404s) and alwayshttp, on a service every launcher here runs over TLS. Neither is visible from the mock: both fail days later at a partner that will not federate. It deletes and re-creates its application and relationship on each run so the counters start at zero, and it puts the attribute back in afinally. No JOB_LOCK: it works entirely inside two realms nothing else in the suite touches. Gated onWSTRUST_STS_URL, and it skips with a different reason when the mock is too old — read offGET /ldap/applications, the schema that service publishes, rather than guessed from a version. Needs the client and the mock and nothing else)federation_chain_sso.js(N-LAYER FEDERATION: one sign-in through THREE trust realms and THREE protocols, with the middle realm as a pure identity BRIDGE — the file above with the bottom knocked out of it. The debugger’s OAuth2/OIDC workflow stands in forwebapp_sso_2, registered infederation-realm-3, which is an OpenID Provider to it and the SAML 2.0 service provider of a relationship withfederation-realm-4. Realm 4 has no password box of its own: its identity-provider-side relationship carriesfedAuthnMechanism: federationandfedAuthnRelationshipnaming its OWN service-provider-side relationship withfederation-realm-5, so it answers the SAML 2.0 request by federating AGAIN over WS-Federation — three protocols, four parties, and the only password field in the chain is drawn at realm 5. That is what makes realm 4 a bridge rather than an identity provider: it checks nothing about the person and re-asserts what it was handed under its own signature, in a protocol the party above it does not implement. Nothing bounds the depth; realm 5 could broker again and the only thing that would change here is the number of hops asserted.fedAuthnMechanismis the attribute this test exists for and it was added to the mock for it: its four values arepassword,password-mfa,webauthnandfederation, and an EMPTY one is deliberately notpasswordbut “this relationship says nothing”, which falls through toappFederationRelationshipexactly as before — seefederation/CLAUDE.mdin the submodule. Everything is provisioned through/admin-apiper realm: three realms (asserted to have three DISTINCT signing kids, which matters more here than with two because the middle realm’s whole job is to RE-SIGN what it just verified), the application, and four relationship records — one per direction per federation — with realm 4 holding exactly one in each direction, which the test asserts because being both at once IS the bridge. Each consuming side is filled in from the partner’s own metadata: realm 4’s SAML 2.0 metadata fetched by the service provider’s entityID, and realm 5’s WS-Federation metadata at AD FS’s/FederationMetadata/2007-06/FederationMetadata.xml, whoseentityIDis also what the assertion’sIssuersays — the mock wraps a SAML 1.1 assertion in the RSTR by default and a SAML 1.1 assertion carries its issuer in an ATTRIBUTE rather than an element, sofedPeeris pinned to that string rather than discovered at the assertion consumer service. The half-configured refusal is asserted before the field that completes it: a relationship set tofederationwith nothing to broker to must reportfedAuthnRelationshipas MISSING, because the alternative — quietly asking for a password — is a broker that has stopped brokering looking exactly like one that works. The happy-path assertions are about the shape and the state: one click carries the browser through realms 3 and 4 without either drawing a screen, realm 5’s screen is the WS-Federation one (wsfed-login, whichwsfed.jshas andauthn.jshas never had — the profile keeps a sign-in screen of its own because section 13.2.1’s POST cannot survive a redirect chain), both consuming relationships counted exactly one sign-in by that user with no error, all three realms hold a directory entry for a person only ONE of them ever asked for a password, and the ID Token saysiss: …/realm/federation-realm-3and mentions neither realm 4 nor realm 5 anywhere in the whole payload. Then the mechanism is mutation-tested four ways, each failing differently:passwordmust put realm 4’s own screen back (the mutation test for the feature — if the chain still reaches realm 5 here, something else is doing the brokering and every assertion above is about that instead),password-mfaandwebauthnmust reach that same screen with a DIFFERENT box ticked and locked (the ceremony is not run — a security key needs a virtual authenticator this suite does not stand up here — but which box the screen locks is the whole of what the mechanism decides before it starts), a mechanism pointing at a DISABLED relationship must NAME it on the screen it falls back to, and a value that is not a mechanism must be REFUSED at the write naming itself, leaving the entry and the screen as they were (iya-sts #86; it was stored and named inreadiness.missingbefore). Those four boxes are read off the DOM and not offgetPageSource(), which returns Chrome’s serialization — a barechecked disabledcomes back aschecked="" disabled="", and a regex written against what the mock actually emits matches nothing and reads as “the mechanism did not lock the box” for every mechanism; it cost a run. The mutation walks drive realm 3’s authorization endpoint directly rather than throughoauth2_oidc_1.html, because what each is about is a decision the MOCK makes and putting the page in front of it would add a page’s worth of ways to fail; the happy path goes through the debugger, where the application tier IS the point. It clears cookies before each walk — a session left by the previous one satisfies the request with no screen drawn at all, and the answer would be “nowhere” for all four mechanisms. Realms 3, 4 and 5 deliberately rather than 1 and 2: those belong tofederation_sso.js, which asserts its relationship counted EXACTLY ONE sign-in, and sharing a realm between two jobs in a pool is a flake rather than a failure. No JOB_LOCK, for that reason. Gated onWSTRUST_STS_URL, and it skips with a different reason when the mock predates the attribute — read offGET /ldap/federations, the schema that service publishes. Needs the client and the mock and nothing else; about ten seconds)
Test files with no note here
Helpers rather than jobs: browser_flags.js, wait_for.js, page_load.js, module_paths.js, run-report.js (the runner). page_load.js is the only one of those with a test of its own — page_load_retry.js — because what it promises is a negative (a page that loaded is not retried) that nothing else would notice losing. Since 2026-09-10 it has two entry points: loadPage(), which takes the id the page must hold, and loadUrl(), the same retry with no id for a caller whose own next line is the readiness check — which is what ~180 navigations across 65 files were swept onto, and what everyTargetNavigationGoesThroughPageLoad() in jwk_pem_encoding.js now holds every test to.
consent_screen.js is a sixth, added 2026-09-01, and it is the one worth reading before editing any OAuth or OIDC job here. The mock STS asks for consent now: the first time a given username signs in to a given client_id for a given scope, /oauth2/consent is drawn and nothing is issued until somebody presses Allow — oauth2.consentRequired, which is ON by default over there and is the one policy in that service that is. So every flow in this suite that signs a person in to the mock has one more hop in it than it had the day before, in the middle of a redirect chain that several of these jobs walk by hand.
It has two surfaces because this suite drives that service two ways. settleAuthorization() is for the jobs that follow redirects themselves with redirect: "manual" — it walks the hops that stay on the mock’s own origin and hands back the first one that leaves, which is the client’s redirect_uri and the thing those jobs were already reading. passInBrowser(driver, By) is for the Selenium jobs: it presses Allow if the button is there and returns quietly if it is not, and it returns IMMEDIATELY once the browser’s URL is no longer on a path the identity service owns, which is what keeps calling it after every sign-in from costing anything.
Neither of them asserts that the screen appeared, and that is deliberate rather than lax: a scope under oauthGlobalConsent, a username that has consented earlier in the same run and the setting turned off are all states in which the screen correctly does not appear, and a helper that insisted on it would make every job here also a test of the consent feature — failing in fifteen places for one reason. What tests the screen is sts/tests/vendored/sts_consent.js, in the mock’s own repository, which asserts that it appears, what is on it, that a GET of it records nothing, that a consent id is spendable once, that it cannot be answered by somebody else’s session, and that a global consent on an application’s entry stops everybody being asked without writing anything about anybody.
Two jobs are the exception to the sweep and each for its own reason. krb5_mit_client.js drives its flow with curl rather than a browser and reads one Location header, so its application is registered with oauthGlobalConsent: ["openid"] instead — which is configuration rather than a workaround, and makes its assertion STRONGER: the only screen that could now appear is the sign-in screen it already forbids. oauth2_device_authorization.js is the Keycloak half and meets Keycloak’s own consent page, which its existing page-walking loop already handles.
Jobs, in the same directory, carrying no entry above:
federation_choice_sso.js(ONE application, TWO federation partners in DIFFERENT protocols, and the person picks which one authenticates them — the job that drives the mock’s chooser at/authn/select-idp.webapp-sso-1is registered infederation-choice-1, which is an OpenID Provider to it and the federation service provider of BOTH a SAML 2.0 relationship and an OpenID Connect one, each pointing atfederation-choice-2. What makes that possible isappFederationRelationshipholding a list: it was single-valued until 2026-08-26, so an application could name one partner and be redirected to it, or name none and be shown the buttons at the foot of the sign-in screen — which offer every relationship the service has, a question nobody can answer about an unfamiliar service. Naming several is the middle answer, andappFederationAutoRedirectstill means without the sign-in screen rather than without a page: with one partner it is a redirect, with two it is the chooser, which is the screen’s job done without the screen. Why it signs in TWICE, and why “a token came back” is not what it asserts. Both relationships work, andfederation_sso.jsand the grid already prove that — running either again from a page with two buttons on it would assert nothing new. What is new is that a choice was OFFERED and HONOURED, and the assertion that catches both halves is arithmetic: after picking the SAML 2.0 button, that relationship has counted one federated sign-in and the OpenID Connect one has counted zero; then, in a second cookie-less run through the other button, the OpenID Connect one reads one and the SAML one still reads one. A mock that drew a two-button page and then federated through whichever relationship it found first passes every other assertion in the file and fails those two. The cookies are cleared before the second run deliberately: realm 1 issued a session on the first, and without clearing it the second authorization request would be satisfied by that session with no chooser drawn at all. Two protocols on purpose, not two of the same, because the claim being made is that what an application’s people choose between is WHERE they authenticate rather than HOW — the two arrive at the same/federation/acs/{id}by completely different routes, one a signed XML document POSTed by a browser and the other a code redeemed over a back channel, and both end in the samestartSession(). The chooser prints the protocol under each button for exactly that reason and the label is asserted; two buttons that both said “SAML 2.0” would be a page nobody could choose from. The page itself is asserted before anything is clicked, and on BOTH visits — one button per configured partner and no more (more would mean the generic list had leaked in, which is a different question entirely), each carrying this realm’s prefix and areturnTonaming the interrupted authorization request, and nousername,password,kc-loginor<form>anywhere on it: that last is what would catch the chooser being turned back into the sign-in screen with its form suppressed, which is one CSS change away from accepting a name an application that federates has said it does not want typed. Also that neither ID Token mentionsfederation-choice-2or the relationship id — the application chose nothing and must not learn what was chosen — and that the two sign-ins describe two different people, which is what would catch the second flow being satisfied by the first one’s session. Mutation-tested in place: the last section REMOVES one of the two values and runs the same authorization request, which must then stop drawing a page at all and go straight to the partner that is left, exactly asfederation_sso.jsasserts — without which every assertion above would pass against a mock that drew a chooser for every federated application whatever its entry said. The value goes back in afinally, because the realms outlive the process. Plus the cheapest negative in the file:/authn/select-idpwith no?authn=id is refused 400 and sets no cookie, because what it draws comes off a pending record and a page composed without one would be a partner list carrying a return address anybody could supply. It letsfederation-choice-1dial its partner’s back channel throughtrustPartnerTls()infederation_admin.js— the OpenID Connect half redeems its code there and that module refuses plain http and an untrusted certificate, since a client secret and an authorization code ride on the request; since iya-sts #171 that isfederation.outboundCaFilenaming the mock’s ownstack-tls-cert.pem, so verification stays on — and restores what it wrote with/admin-api/config/resetrather than by writing the old value back, because asetleavessource: overrideon the row for the mock’s own suite to trip over next run. Its own realms (federation-choice-1and-2) forfederation_chain_sso.js’s reason: it asserts exact counts on two relationships and performs two sign-ins, so a shared realm would make its arithmetic depend on what else had run. No JOB_LOCK. Gated onWSTRUST_STS_URL, and it skips with a different reason when the mock predates the feature — read offGET /ldap/applications?format=json, whereappFederationRelationshipmust publishkind: "multi", which is the feature’s own schema signal rather than a proxy for it. Needs the client and the mock and nothing else)api_connect_timeout.jsbbs2023_cryptosuite.jsedge_landing_contract.jsencoding_tools.jsjose_jwe_encryption.jsjwt_tools.jsjwt_vc_json_common.jsjwt_vc_json_issuance.jsjwt_vc_json_issuance_waltid.jsjwt_vc_json_presentation.jsjwt_vc_json_presentation_waltid.jsldp_vc_issuance.jsldp_vc_presentation.jsldp_vc_refresh.jsmetadata_schema_validation.jsnavigation.jsoauth2_delegation_chain.js(a three-tier delegation chain: one OIDC sign-in, then two RFC 8693 hops made as two further clients, each out of a debugger workflow of its own.oauth2_token_exchange.jsperforms ONE exchange, in the workflow that obtained the subject token, and confirms it by introspection — which is the right test of that pane. What it cannot show is what an exchange is FOR: the token one hop issues is the token the next hop presents, and after two hops the far end holds a credential for somebody who never spoke to it. bob_end_user signs in towebapp1foropenid email profile offline_access apigw1;apigw1exchanges that token for one scoped… esb1;esb1exchanges again for… sp1. Nothing simulates the API call each hop is supposedly reacting to — there is no resource server here and inventing one would be testing a fixture. Four decisions worth knowing before changing it. (1) The localStorage is CLEARED between hops, which is what makes them separate applications rather than one workflow with the fields retyped: the debugger keeps the whole workflow in browser storage, so an exchange run straight after a sign-in inherits the client, the endpoint and the token, and a pane that filled in nothing would still send a well-formed request. The clear is asserted through the page’s own re-seed — a cleared store never survives to be observed, because a page with noinitializedflag writes its unconfigured defaults straight back — andpopulateMetadata()then proves discovery re-ran. (2) The request PREVIEW is asserted before the button is pressed, all four of client, scope, audience and subject token, because each is a field that could be stale from the hop before and a correct-looking response cannot tell you which one was sent. (3) It PROVISIONS the four applications first and each downstream one registers an AUDIENCE. This service accepts a client_id it has never heard of, so an entry otherwise appears only when something is accepted and appears knowing nothing but the identifier. Here all four are created throughPOST /admin-api/applications/createbefore the browser starts — declared foroauth2andoidc, named for the tier each one is (the registry’sappNameis what labels the box, so the map readsapigw1 (API gateway)) — and the three that a token can be ADDRESSED to register the URI it is addressed by onoauthAudience, an attribute added to the mock’s application schema on 2026-08-26 for this. webapp1 registers none and the test asserts the absence: a browser application is what a token is issued TO and never what one is addressed to. The hops then ask for the URI rather than the client_id, so each token’saudis a real resource — and that is why the attribute is read rather than decorative. A scope that names an application in the registry is READ AS AN AUDIENCE, so no token here carries the next tier in its scope at all — the mock gained that reading in the same 2026-08-26 bump, and it is why the sign-in’s token is addressed toapigw1(the scope value verbatim, beside the service’s own/resource, which theopenidscope keeps so UserInfo stays reachable) while its scope claim is the base four alone. The two exchanges sendaudienceoutright and that parameter wins theaud, but their scope lists are trimmed the same way. Every assertion reads the next tier out ofaudand asserts it is absent from the scope; reading it out of the scope is what this file did until that bump, and the failure named the scope claim rather than the reading that had moved. The register is keyed by the identifier an application PRESENTS, so an act filed underhttps://esb1.example.comdraws a box nothing else in the picture mentions and the chain comes out as two unconnected halves; the mock now resolves the audience throughapplications.forAudience()and files the act againstesb1, keeping the requested URI in the sentence beside it. Both halves are asserted — the target is the application AND the explanation names the audience — because either alone cannot tell a resolution from an exchange that simply named a client_id. Nothing is refused by any of it: an audience nobody registered is exchanged for as before and recorded verbatim. A mock STS older than that date fails the job at the create, by name, rather than three screens later as a delegation whose target is a URL. The four entries are LEFT BEHIND deliberately: they are what the map is a picture of, no other job uses those identifiers, and no job here counts what this service holds any more — the one that did, the mock’s ownadmin_api.js, was exclusive and left this suite on 2026-08-28 — which is why this script is not in JOB_LOCKS despite writing to a shared store. (4) IMPERSONATION: no actor token, so what comes back names the person and says nothing about the middle tiers — which is the mode worth drawing, since the issuer’s register is then the ONLY place the chain exists at all. The mode is asserted on the row, so a Delegation radio flipped by accident fails here rather than quietly changing the picture. It asserts in four layers, because any one of them passes alone: the REGISTRY (each entry read back throughGET /admin-api/applications, not off the create’s own reply — both protocol families declared, and the audience registered or deliberately absent); the wire (200, decodable, names bob_end_user, ADDRESSED to the next tier, and carrying that tier’s name nowhere in its scope, noact); the ISSUER’s reading, by introspecting the final token, so the audience and the scope are confirmed by the server that signed it rather than by this test decoding a string; and the REGISTER — the two acts recorded since the job started, matched to the jti of the tokens the browser actually received, plus the graph/admin/delegation/mapis drawn from, where the property that makes this a chain is thatesb1is ONE box that was the target of the first hop and the intermediary of the second. Deltas throughout: the mock’s register survives between jobs and the suite runs in a pool, so the newestseqis read before the browser starts. And it SAVES THE PICTURES, which is why the job exists — that register is in memory and dies with the container, so the map of a chain can only be drawn while the run is happening.?format=svgoff/admin/delegation/map, the two/admin/delegation/chaindocuments and/admin/delegation/applicationfor the middle tier, written toDELEGATION_ARTIFACT_DIR(the run’s own report directory underrun-report.js). The console is gated, so it signs in the way the mock’s own console tests do; if some other job has narrowed the console roster the drawings are skipped with a message naming the gate, and no assertion depends on them, because they read/admin-api/delegation, which is not behind it. Gated onWSTRUST_STS_URL— the mock only, and its permissive realm: three clients nobody registered, a user with no password, and a token endpoint that will exchange anything for anything, which the mock says itself in every row (authorizedBy: 'nothing. RFC 8693 leaves the policy to the authorization server and this one has none'). Keycloak would need three clients, a user and a token-exchange permission per pair provisioned first; the RFC 9700 realm has opinions about a public client and about a scope that grows, so pointing this at one is deliberately not supported. Since 2026-08-26 it also asserts the CREDENTIAL’S ANCESTRY, at the mock’s new/admin/tokens/credential?id=…— the tokens page’s first drill-down, reached from every identifier in its last column. That page walks the delegation register by identifier (the jti a hop produced is the jti the next hop consumed) back to the issuance the line rests on, and this job is the only place in the suite that produces a credential with two generations behind it. The assertion is the three jtis the BROWSER received, in order, against the generations the page walks — a lineage that agreed about the shape and named different tokens would be a page describing somebody else’s chain — plus: the oldest generation is an issuance rather than an exchange, it is webapp1’s and its grant isauthorization_code; nothing is truncated and no line stopped at a credential the register cannot name; and the picture has a box for webapp1, which exists on that page and on no other, because webapp1 exchanged nothing and therefore appears in no delegation act. It also asserts the LINK itself, against the tokens page’s markup rather than its JSON, since the requirement is that the identifier is a link and JSON has none. And since 2026-08-26 it asserts the two CHOOSERS on/admin/delegation, which are a search rather than a<select>— this is the one job in the suite that can, because it is the one that puts four known names in that register: a search forapigw1must find it and must NOT drawesb1orsp1(a box drawn and ignored passes everything else), a term nothing can match must draw an empty pane rather than the whole list, each pane must hold at most the twenty its own line promises, and neither<select id="application">nor<select id="user">may be on the page — that last one is the regression guard, since a select and a search box look alike in a diff and a page that grew the select back would pass every other assertion here. It needs a mock STS from that date; an older one fails on the select, by name. All of it goes through the console’s gate, so it is skipped with a named reason when some other job has narrowed the roster — the same arrangement the drawings have, and no assertion above depends on it. And since 2026-09-01 it configures the DELEGATED PERMISSIONS the chain implies, which is the second register on that console page — the acts are evidence, a permission is intent. Each resource exposes an API under the audience it already registered (https://apigw1.example.com/and so on, normalised, and the normalised spelling is added tooauthAudiencebeside the original becauseforAudience()matches exactly) withreadandwriteon it, and each tier is granted both on the tier it forwards to: webapp1 → apigw1, apigw1 → esb1, esb1 → sp1. webapp1 exposes nothing, asserted the way its empty audience is. It changes nothing about what the job sends — the hops still name the next tier’s bareclient_idinscopeand its URI inaudience, because that is the deployment shape being copied, and a bareclient_idscope is the DEFAULT permission, the whole API unnamed. It refuses nothing either:oauth2.delegatedPermissionsEnforcedis off by default, which is exactly why the grants are made now rather than when it goes on../local-run-tests.sh --delegation-onlyis the ~1-minute loop, and the one way to get the drawings without a twenty-minute run. It runs against iya-sts in either mode, and asks the service which (GET /admin-api/mode, never a flag of its own). In PRODUCT mode the provisioning step also writes what that mode refuses without — bob_end_user with a password (DELEGATION_USER_PASSWORD, else generated), webapp1 public with PKCE and its callback registered, a generated secret on apigw1, esb1 and sp1 (introspection authenticates there), declared scopes and grant types, the debugger’s origin onappCorsOrigin, and the delegation policy’s impersonation rules (appDelegationSemantics,appDefaultDelegationSemantics,appAllowedToDelegateTo: apigw1 → esb1, esb1 → sp1) — headless Chrome presents a desktop User-Agent so the sign-in is not refused as an automated client, and each act is held to the policy having ALLOWED it. The policy’s semantics are written in development too wherever the service has them, because its realm default is delegation. Against the static console (iya-sts #446) the console reads go through the/admin-apioperations they mirror, and the map SVGs, now console-only operations, are saved as JSON graphs instead)wstrust_delegation_chain.js(the same three-tier chain in the other protocol family: a SAML 2.0 HTTP-POST sign-in, then two WS-Trust hops carrying assertions. carol_end_user signs in toportal1over the Web Browser SSO profile and the assertion comes back addressed tohttps://portal1.example.com;portal1presents it to the STS in a RequestSecurityToken with<wsp:AppliesTo>https://esb.example.comand gets back a NEW assertion — same person, new audience restriction;esbdoes it again forhttps://soap1.example.com. Nothing simulates the SOAP call each hop is reacting to, for the reasonoauth2_delegation_chain.jsgives about the API call. Why it is worth running beside that one: an<saml:AudienceRestriction>IS anaudclaim — SAML 2.0 §2.5.1.4 and RFC 7519 §4.1.3 make the same statement, and<wsp:AppliesTo>is what an STS copies into one — so each hop is a token exchange in the sense RFC 8693 means it, and the delegation register and its map are supposed to be ONE model for both families. A chain that draws correctly for OAuth and comes out as unconnected boxes for WS-Trust would mean the model only ever worked for the family it was written against. TWO JOBS, one per delegation element (WSTRUST_DELEGATION_ELEMENT), because they are two mechanisms rather than one with two spellings:<wst:OnBehalfOf>(1.3 §9.2) is IMPERSONATION — what comes back names the person and says nothing about the requester, so the register is the only place the middle tier exists — and<wst14:ActAs>(1.4 §9.3) is composite DELEGATION. The mode is asserted on the row and on the picture’s own edge, so an element flipped by accident fails here rather than quietly changing what the map means. They are two jobs and not one loop so that a failure names which element it was carrying, and they may overlap in the pool: every assertion is a delta on the register’s newestseqAND filtered by TYPE, since the other run has the same person, the same applications and the same two addresses. It PROVISIONS three applications first, in the default realm, throughPOST /admin-api/applications/create:portal1(SAML 2.0 + WS-Trust) registering its entityID onsamlEntityIdand NO AppliesTo — it is what an assertion is issued to and no RST here ever names it — andesbandsoap1registering their address on bothwstrustAppliesToandsamlEntityId, which are two registrations rather than two spellings. That registration is READ, which is the whole point: the register is keyed by the identifier a party PRESENTS, so an act filed underhttps://esb.example.comdraws a box nothing else in the picture mentions and the chain comes out as two unconnected halves — the address hop 1 reached and the name hop 2 authenticated AS are one application under two names. The mock resolves it throughapplications.forAppliesTo()(added 2026-08-27,forAudience()’s shape arriving through a second protocol) and files the act againstesb, keeping the address in the sentence beside it; both halves are asserted, because either alone cannot tell a resolution from an RST that simply named an application. That lookup skips the entry named by the address itself, and it has to:wstrust.js’s ownseen()files every AppliesTo it accepts as an identifier, so this registry always holds an entry calledhttps://esb.example.coma moment after the first request for it — without the skip the chain still drew as two halves and the only symptom was a duplicate warning in the mock’s log. Nothing is refused by any of it: an AppliesTo nobody registered is issued for as before and recorded verbatim. The AuthnRequest is deliberately UNSIGNED —saml_sso.jsis where the debugger’s request signatures are tested, against an identity provider that validates them; the mock verifies none, so signing here would prove nothing and would tie this job to the per-run SP key pair. It needsWSTRUST_STS_URLand nothing else. It asserts in five layers: the REGISTRY (each entry read back throughGET /admin-api/applications, not off the create’s own reply); the SIGN-IN (a Response over the POST binding, an Assertion in it, the person’s name, and the AudienceRestriction that is the web application’s URL — the fact that makes the rest a delegation rather than a re-issue); the WIRE (each RSTR parses, names the same person, has a NEW AssertionID, and is addressed to the next tier and to NOTHING else, asserted both ways because an STS echoing its input satisfies the first alone); the REGISTER (the acts since this job’s baseline, with their parties, type, mode, the AssertionID this test received and the AssertionID it delegated WITH); and the PICTURE plus the LINEAGE — the graph/admin/delegation/mapis drawn from, whereesbmust be ONE box that was the target of the first hop and the intermediary of the second, then/admin/tokens/credentialwalked back from the final assertion, which must reach the SIGN-IN assertion three generations away. That last one needed the mock to record what an OnBehalfOf was delegated WITH: the act’sconsumednamed only the requester’s WS-Security credential, which this service never issued and cannot name, so every WS-Trust lineage stopped one generation in at a wall. The request PREVIEW is asserted before Send, all four of the UsernameToken, the AppliesTo, the delegation element and the ID of the assertion being delegated with, and the localStorage is cleared between hops — both for the reasons the OAuth chain’s entry gives. It SAVES THE PICTURES toDELEGATION_ARTIFACT_DIR, every file namedwstrust-…and carrying the element so that the two jobs and the OAuth one cannot overwrite each other, through the same console sign-in and with the same skip-with-a-reason when another job has narrowed the roster. Needs the api as well as the mock — the identity provider POSTs its response to the api’s assertion consumer service — so it is skipped on a backend-less target../local-run-tests.sh --delegation-only=wstrustis the loop;--delegation-onlyalone now runs this AND the OAuth chain)oauth2_device_authorization.jsoauth2_metadata_rfc8414.jsoauth2_resource_owner_password_credentials_grant.jsoauth2_token_exchange.js(one Token Exchange (RFC 8693) through the pane, confirmed by introspecting what came back — and, since the pane grew tabs, the three tabs on the Token Exchange Results pane. Result is the textarea it always had; HTTP is the exchange that carried it; Tokens is what it issued. The HTTP half is worth asserting for the reasontoken_http_exchange.jsgives about the pane above this one: this pane’s default is to have the api call the token endpoint, so the request that matters is made by another process, and the only reason the browser can show it is thatPOST /tokenexchangenow hands back what it saw underhttp_exchange— which that endpoint did not support at all until the tab existed. That chain fails SILENTLY: the pane goes on rendering and shows the browser’s own call to the api instead, a perfectly plausible exchange with the wrong URL in it. So the URL is asserted to be the token ENDPOINT, read off the page’s own field, and the note to name the end that made the call — read off the page as well, since a static deployment has no api and makes the call from the browser. The Tokens tab is asserted against the response the Result tab was parsed out of, because the pane is rebuilt on every call and a stale token there reads exactly like a successful exchange; and the localStorage key behind it is asserted too, since that key is the only reason the Issued Token, Introspect Token and Revoke Token controls beside it do anything. It also pins that there is exactly ONE tab strip after the rebuild)oauth2_token_revocation.jsoid4vci_request_encryption.jsoidc_dynamic_client_registration.jssaml11_sso.js(the DEBUGGER’s SAML 1.1 service provider, through its pages, once per binding —saml_sso.js’s sibling and deliberately its mirror image. It took this name on 2026-08-25 from the file now calledsts_saml11.js, which drives the mock’s identity provider directly and is described below; the rename is the whole of the difference, because until that day the SAML workflow was SAML 2.0 SP-initiated and selecting 1.1 returned an XML comment where a request would be. There is no Keycloak half and there will not be one — Keycloak dropped SAML 1.1 years ago — so the mock is the only identity provider, andsts_saml11.jsis what compensates for that by writing its own relying party. What it asserts thatsaml_sso.jscannot, six things and each a place SAML 1.1 is a different protocol: the Protocol Version selector is MOVED BY THE METADATA (protocolSupportEnumerationnames the 1.1 protocol, and a page left on 2.0 would post an<AuthnRequest>at an inter-site transfer service); the request is not a DOCUMENT but Shibboleth’s four query parameters, read back out of the preview and matched as a decoded map rather than as substrings; four settings are asserteddisabledrather than merely greyed, because a block that only looks dead still submits on a Return keypress; the status code is a QName (samlp:Success, not a URI ending:status:Success), which a 2.0-only reader renders as a failure over a sign-in that worked; the confirmation method IS the profile (cm:artifactfor Browser/Artifact,cm:bearerfor Browser/POST — a relying party that does not check works with either), asserted per binding along withDoNotCacheCondition, which the POST profile carries and the artifact one must not; and the assertion’s signature verifies THROUGHAssertionID, a name a generic verifier does not search. The three bindings mean: redirect = a top-level GET to the inter-site transfer service, post = the same parameters as a form POST (SAML 1.1 defines no POST-bound request at all — that is the mock reading a non-standard one), artifact = a GET plus Browser/Artifact, whoseSAMLartthe API resolves over the SOAP binding and which therefore needs the api, so it is skipped on a backend-less target exactly as the SAML 2.0 artifact job is. Gated onSAML11_METADATA_URL, the mock’s per-relying-party 1.1 descriptor, which that service mints for any identifier asked for. The job REGISTERS the relying party first all the same — entityID and assertion consumer service, throughtests/sts_applications.js— and it matters more in this profile than in SAML 2.0: SAML 1.1 has no request message, so the registry entry is the only place this party’s audience and its ACS are written down as facts rather than inferred from aproviderId, a path segment or a guess at the TARGET’s origin. Shares thests-saml11JOB_LOCK withsts_saml11.js— see that entry. Seedocs/saml11.md)saml11_options.js(which SP / Request settings apply to SAML 1.1 and which are switched off — no identity provider at all, which is the point. The round-trip jobs above cannot isolate this: a control wrongly left live is invisible there as long as the flow works anyway, which it does. Five settings have no meaning in SAML 1.1 — the username hint, request signing, request encryption, Single Logout and the three SLO endpoint fields — and each is asserted twice: thesaml-unavailableclass, which is what a reader sees, and thedisabledattribute, which is what a Return keypress obeys.tests/pki_page.jsasserts the same two properties separately about#pane_tlsand for the same reason. It also asserts the OPPOSITE for the SP key pair, which stays live and visible because it still signs the artifact back-channel’s SOAP request and is still the SP metadata’sKeyDescriptor— a sweep that greyed everything to do with signing would take away two things that work, and this is what stops it. Plus: the version notice explains what 1.1 sends INSTEAD of an AuthnRequest rather than only that something is unavailable; the request preview per binding, parameters decoded (a URL percent-encodes its values, so a substring match passes only while every value happens to be free of reserved characters);Call IdPrefuses BY NAME with noshireand does not navigate;singleLogout()refuses saying the PROTOCOL has none rather than reporting a missing endpoint; the SP metadata this page builds declares the 1.1 protocol, binds its assertion consumer endpoints with the two PROFILE URIs, and carries neither aSingleLogoutServicenorAuthnRequestsSigned; and switching BACK to 2.0 restores everything, since a one-way transition survives any test that only ever selects 1.1. With the HTTP-Artifact SSO field left EMPTY it also asserts that Browser/Artifact is offered on the response binding selector and absent from the request one, and that the note under it names the SAML responder the artifact is resolved at (issue #307). Never skipped. Seedocs/saml11.md)sts_saml11.js— Retired here on 2026-10-22: iya-sts’s own suite owns it (sts/tests/vendored/sts_saml11.js, alocaljob there), so this suite no longer runs a second copy against the mock.sts_saml_encryption.js— Retired here on 2026-10-22: iya-sts’s own suite owns it (sts/tests/vendored/sts_saml_encryption.js, alocaljob there), so this suite no longer runs a second copy against the mock.saml_logout.js(runs TWICE, once per identity provider, since 2026-08-24 — seesaml_sso.jsbelow for the pair. The interesting difference between them is where theLogoutResponseis SENT: a<samlp:LogoutRequest>carries no return address, so Keycloak reads the SP metadata it was configured with, and the mock STS — which publishes metadata and does not CONSUME it — reads thesamlSingleLogoutServicedeclared on the service provider’s directory entry, falling back to the assertion consumer service URL it last used and logging that as a guess. The launchers declare it throughPOST /admin-api/saml2/set-logout-serviceso the run exercises the declared path; the fallback happens to be right for this stack anyway, because the api’s/samlacsand/samlsloare one handler)saml_operation_history.js(the shared SAML Operations History log — every attempted call to the IdP with its binding, protocol version and result, written onsaml_request.htmland resolved onsaml_response.html. Its “a version that cannot be sent” case is SAML 1.0 and used to be 1.1, which had to move when 1.1 became a working profile: selecting it and clicking Call IdP now hands the browser to an identity provider instead of recording a refusal, so the check would hang waiting for a row on a page that had navigated away. SAML 1.1’s own pre-dispatch refusals — no inter-site transfer address, noshire— are a different shape and live insaml11_options.js. Needs no IdP: the pre-dispatch failures come from the page’s own checks, the dispatch aims at a URL on the test server, and the resolution is driven by handing the response page a crafted SAMLResponse)saml_sso.js(each of the three bindings runs TWICE, once per identity provider, since 2026-08-24: against the Keycloak realm (gated onSAML_METADATA_URL) and against the mock STS, which grew the SAML 2.0 Web Browser SSO profile in its own repository that day (gated onSAML_STS_METADATA_URL, which the launchers set wherever the STS is reachable BY THE BROWSER). The same arrangementwsfed_sso.jshas had since 2026-08-15 and for the same reason: a mock that is quietly more permissive than the real thing passes every test written against it alone. The two are complementary. Keycloak is somebody else’s implementation and the only interoperability evidence here, and it VALIDATES the AuthnRequest signature against the certificatecommon.shregistered for this run — so a request the debugger builds sloppily fails there. The mock answers the HTTP Artifact binding with a real SOAP back channel in a service that starts in seconds, refuses aProtocolBindingit does not implement BY NAME, and answersIsPassivewithNoPassiverather than a screen. THREE differences are markedIDP:in the test — where the metadata document lives, whether the service provider has to be provisioned first, and which of them can be driven with no side-car — and the second is the one that matters in practice: the mock needs NOTHING provisioned, since it accepts any entityID, mints a metadata document for anything asked for, and creates the application entry from the first valid AuthnRequest. Its metadata URL carries a DIGEST of the service provider entityID because that service publishes metadata per service provider, so the launchers compute the segment rather than guessing it../local-run-tests.sh --saml-onlyruns the SAML jobs alone against both;--saml-only=stsis the fastest loop.saml_encrypted_sso.jsis still not paired, and the reason CHANGED on 2026-08-27: the sentence here used to be that the mock’s Web SSO profile encrypts no assertion, which stopped being true that day. It encrypts now — seests_saml_encryption.jsbelow — and the pairing is still not worth having, because that job’s value is that Keycloak produced the ciphertext and the debugger’s page decrypted it in the browser. Anstshalf would re-prove the mock’s encryption through a browser, which the node job already proves more thoroughly and far faster The artifact job also holds issue #307’s decoupling: before selecting anything it BLANKS the HTTP-Artifact SSO field, then asserts the HTTP-Artifact choice is still offered on the RESPONSE binding (theProtocolBinding) selector, that the REQUEST binding selector offers no artifact at all, and that an Artifact Resolution Service address is present — then completes the round trip. The mock stopped advertising an HTTP-Artifact SingleSignOnService in iya-sts #191 and Keycloak still advertises one, so blanking the field is what makes both halves assert the same thing)saml_tools.jssaml_authnrequest_page.js(the SAML Request Decoder —saml_authnrequest.html, which reads anAuthnRequestsomebody else built rather than building one. Needs no identity provider and no api: every fixture is built inside the test by the same two modules the page uses (common/xmldsig.jsandclient/src/saml_message.js), so a fixture is a real deflated, signed redirect URL rather than a recorded string that rots. The assertion this file exists for is the redirect TAMPER case. A redirect-binding signature covers the query string as SENT — ordered, percent-encoded,Signatureexcluded (saml-bindings-2.0-os§3.4.4.1) — so a decoder that rebuilds those octets in its own order, or from values it has decoded, reports INVALID on a signature that is perfectly good, and in a browser that is indistinguishable from a wrong certificate. Checking only that a good signature verifies passes with the octets rebuilt any which way, as long as they are rebuilt consistently, so both directions are asserted and the signed octets themselves are read back off the page. The other five phases: the POST binding, where the signature is an enveloped<ds:Signature>verified from the document’s own KeyInfo with no certificate pasted, plus a byte altered inside the signed subtree so the REFERENCE DIGEST fails rather than the SignatureValue; an encrypted request, where the Details table must describe the ciphertext and not invent an Issuer row, then decrypt and re-render from the plaintext, then fail on the wrong key; an artifact, which carries no message at all, so the XML tab must stay EMPTY rather than showing the previous decode’s; both crypto panes being genuinely optional, read throughgetComputedStylebecause the markup hides them with a class and a reader that trusts the inline style agrees with whatever set it; and that the pasted private key is never written, which is the one claim this page makes that no other page in the family does — it has no key-material opt-out because it never stores, so the key is pasted, used, and then every localStorage and sessionStorage value is searched for a 60-character needle from its PEM body, a substring rather than the whole string so that a re-wrapped copy would still be found. It also follows the Tools-pane link from all three pages that carry one, since a link is the only route to this page — there is no landing card — and asserts the browser console is clean, which is not decoration here: the inflate that separates the two bindings is EXPECTED to fail on every POST message, and the writable half of aDecompressionStreamreports that failure separately from the readable half)saml_response_decoder_page.js(the SAML Response Decoder —saml_response_decoder.html, the mirror of the job above on the other half of the exchange, and a bigger one because a response carries three things a request does not. Needs no identity provider and no api: every fixture is built inside the test by the same two modules the page uses (common/xmldsig.jsandclient/src/saml_message.js), so a fixture is a real signed, encrypted response rather than a recorded string that rots. The assertion this file exists for is SAML 1.1’s STATUS. SAML 2.0 writes the code as a URI ending:status:Success; SAML 1.1 writes it as a QName —samlp:Success— resolved against the document’s own namespace declarations, so a reader that checks the 2.0 suffix reads every 1.1 SUCCESS AS A FAILURE, which is exactly what the SAML Response page did until 2026-08-25 and is the worst possible way to be wrong about a sign-in that worked. The 1.1 case therefore asserts the VERDICT rather than that a table rendered — a page of correctly-populated cells over a red status is what it catches — and beside it every 1.1 spelling:ResponseIDfor the message id,Recipientwhere 2.0 hasDestination(and NODestinationat all), an issuer that is an attribute on the assertion and is missing from the message entirely,<saml:NameIdentifier>,AudienceRestrictionConditionlabelled with its OWN name, a<saml:ConfirmationMethod>CHILD element rather than aMethodattribute, and an attribute whoseAttributeNamespaceandAttributeNameare rejoined into the claim URI they came from. The second assertion is that the two signatures are different claims: a response may be signed on the<samlp:Response>, on each<saml:Assertion>, or both, and only the assertion signature survives the assertion being lifted out while only the message signature covers the Status and the InResponseTo — so a response signed at one level must report the other as absent BY NAME, and one signed nowhere must report that as a finding. A decoder that collapsed them into one “signed: VALID” would pass every other check in this file while telling somebody their unsigned assertion is safe. The other phases: the POST binding with both signatures VALID and then a byte altered inside the assertion, which must fail BOTH (the assertion’s own digest, and the message’s, which covers it); a failed response, where the status IS the whole message, both StatusCode levels are read and no assertion is invented; the Redirect binding carrying a LogoutResponse with a detached query-string signature, VALID as sent and INVALID with one parameter altered; an<saml:EncryptedAssertion>, where the ciphertext must be DESCRIBED and not guessed at, the status must survive it, and after decryption the plaintext is spliced back into the response — the status and the InResponseTo still there — with the assertion’s own signature still verifying, which it only does if it is checked against the plaintext as decrypted; an artifact; a bare<saml:Assertion>pasted on its own, which must not be reported as a response and whose ONE signature must be counted once rather than twice; both crypto panes being genuinely optional; that the pasted private key is never written, searched for as a 60-character needle across both stores; and the layout with all five tabs full. It also follows the Tools-pane link from all FOUR pages that carry one, since a link is the only route to this page, and drives the Expand / Collapse All switch on all four pages that have one. It also drives every control on the page rather than only the protocol, which is the half a test written about SAML leaves out and the half that ships a Copy button copying the wrong field: all five collapsible pane titles (which call the BUNDLE’stogglePane, a different function from the inline switch above them), each Copy button down BOTH branches — a stubbednavigator.clipboardfor the value, and a stubbeddocument.execCommandfor the fallback that is the only path the containerized stack ever takes, from the tab the field is actually on, sincefocus()inside a hidden panel does nothing — Clear (every field AND every table, because a Clear that leaves the previous message’s assertions on screen is worse than none), the SP key-pair prefill in both directions with a check that reading storage did not ADD to it, the certificate hand-off through tosaml_cert.htmlincluding the return link it draws (an entry insaml_cert.js’s caller map that nothing else here would miss), the Binding selector overriding the auto-detection in all three directions, the three?SAMLResponse=/?SAMLart=/?url=seeding parameters — each asserted to reach the ORDINARY decode, the seeded URL by verifying the signature it arrived with — the four refusals (nothing pasted, parameters with no message, base64 that is not XML, and a SAMLRequest pasted here, which decodes and links to the page written for it), a<samlp:ArtifactResponse>whose envelope status and carried status are read from SEPARATE containers, three assertions with one inside an<saml:Advice>, and which TAB each kind of decode lands on — that control is inline script rather than a bundle export so that it works before browserify’s global exists, which is also why nothing else here would notice it silently doing nothing)token_introspection.jskrb5_pac_layout.js(the Windows PAC —common/krb5/krb5_pac.jsandkrb5_ndr.js. This is the worst case in the repository for self-consistency testing, and the entry exists to say why: the logon information is NDR, [MS-RPCE]’s RPC marshalling, where a reader and a writer sharing ONE misunderstanding agree perfectly with each other and with nothing else in the world. ReadFILETIMEas an 8-aligned 64-bit integer in both halves and every field still round-trips — the two just insert and skip the same four bytes of padding no real KDC ever wrote. So the assertions are byte offsets counted out of [MS-PAC] section 2.5’s field list by hand (UserIdat struct offset 100, because six FILETIMEs are 48 bytes and six RPC_UNICODE_STRINGs are 48 more), fixed patterns from the spec (01 10 08 00 cc cc cc cc), and structures with more than one element — anExtraSidsarray defers ALL its SID pointers past the END of the array, so a reader that follows each as it goes is right for one element and wrong for two, and a one-element test passes on the broken implementation. The four signatures are checked separately, including the case that matters: altering the PAC’s contents breaks the server and extended KDC signatures and leaves the KDC signature verifying, because that one covers only the server signature’s bytes — the shape of CVE-2022-37967, and a test asserting merely “a signature failed” would pass against a verifier checking the wrong one. Eighteen mutations, all caught; two were real bugs it found — a signed/unsigned comparison that made the two-bitSE_GROUP_LOGON_IDunmatchable, and a consistency check reading a field one line before it was assigned. Node only)krb5_codec_sync.js(the codec exists TWICE —common/krb5for the browser and the api, and a vendored copy in thests/submodule for the KDC, because compose builds that service withcontext: ./stsand a Docker build cannot COPY from outside its context. A file comparison alone would not be enough: the failure mode of a vendored wire codec is not “one copy is broken” but that BOTH are self-consistent and disagree only with each other, each one’s own tests passing, with the symptom an integrity failure indistinguishable from a wrong password. So the copies are cross-checked behaviourally — one encrypts and the other decrypts, one builds a PAC and the other verifies its signatures, in both directions, because a shared misunderstanding of what a signature covers survives one direction and not both. Then the files are compared too, naming the one command that fixes drift (common/krb5/sync-to-mock-sts.sh). With the submodule uninitialised it says so and passes — an uninitialised submodule is an EMPTY DIRECTORY, and reporting that as a codec failure sends somebody looking in the wrong place)krb5_windows_vectors.js(the recorded exchange from a real Windows KDC, asserted offline.krb5_real_dc.jsneeds a domain controller on EC2 and therefore runs almost never, so its 2026-08-16 run against Windows Server 2025 was captured message-by-message intotests/captures/windows-server-2025.jsonbykrb5_capture_real_dc.js, and this re-checks it on every ordinary run — no AWS, no network, no services, never skipped. It asserts what we READ out of Microsoft’s bytes rather than re-encoding them, because a Kerberos message carries a nonce, a timestamp and a fresh session key and a byte-comparison would only be testing the clock. It has already paid for itself: the capture showed that Windows sends nos2kparamsinPA-ETYPE-INFO2, relying on the RFC 3962 default, while the mock always sends one andkrb5_as_exchange.jsasserts it is present — so a client that required the field would pass this whole suite and fail against every real domain with a wrong-password error. Also pinned here:PREAUTH_FAILEDandC_PRINCIPAL_UNKNOWNreading as distinct codes, thektpasskeytab opening the service ticket, the seven PAC buffers a 2025 DC actually emits — including both post-CVE-2020-17049 signatures and notPAC_ATTRIBUTES_INFO/PAC_REQUESTOR, which the mock does put in a service ticket — and the PAC’s server signature verifying with the keytab key while the KDC signature is correctly reported as unchecked rather than failed)krb5_real_dc.js(the only Kerberos test that talks to software this project did not write. Every other one runs against the mock KDC in thests/submodule, which was written from the same reading of RFC 4120 and [MS-PAC] as the client it checks — so the two agree by construction and a shared misreading shows up in none of them. This drives a real Windows Server 2025 domain controller: a bare AS-REQ refused withPREAUTH_REQUIRED, asserting the salt is AD’s<REALM><samAccountName>with no separator, which is the first place a client and a real DC diverge; an AS-REQ withPA-ENC-TIMESTAMPfor a TGT; a TGS-REQ for a mapped SPN; the service ticket decrypted with the key Microsoft’s ownktpasswrote into a keytab, sokrb5_keytab.jsparses a real Microsoft file; the PAC parsed and its server signature verified with that key; and an AP-REQ built bykrb5_client.jsopened with it. Skipped unlessKRB5_DC_HOSTis set, and no launcher sets it — the DC costs money (free tier is 1 GiB, a forest promotion needs more, sot3.mediumis the floor) and nothing should start one by accident../infra/krb5-test.shis the only thing that does: applyinfra/terraform-krb5, wait for the forest, run this, destroy, with the teardown on anEXITtrap so a failed test still removes the instance. That stack is a separate root module with its own state key and its own VPC, so destroying it cannot touch either static site)krb5_crypto_vectors.jskrb5_codec.jskrb5_describe_output.js(what the decoder page SHOWS and what every exchange page’s message panes show, with no browser. The newest section is the one to read first: a PRESENTED ticket is opened from what the workflow already knows —options.reportedas a list of the KDC’s own reports keyed byticketHex, which is how a TGT nested inside a TGS-REQ’sPA-TGS-REQshows its flags, session key and times with no key anywhere. Its negative is the point of it and is mutation-tested twice: a report for a DIFFERENT ticket of the SAME principal must be ignored, because two tickets for one service are not one ticket and a report printed under the wrong ciphertext is a confident lie; and a key that really opens the ticket must still win, with the report dropped rather than shown beside the plaintext. Two more sections were added on 2026-08-17 with the short-lived Decryption keys pane, and both assert TEXT rather than a boolean because the text is the product: an unopened ticket must name the principal whose key opens it, must name NO page and NO pane (where a key can be supplied differs per page), must NAME the keys it tried, and must keep “no key supplied” and “tried and none worked” as different answers — the second is the branch every page actually reaches, since each of them holds a key of the ticket’s own etype and none of them holds the service’s. The other section covers the two REQUEST-side parts that had never been opened:PA-ENC-TIMESTAMPat key usage 1, and the entire AP-REQ insidePA-TGS-REQ, whose Authenticator is at key usage 7 rather than 11 because it is addressed to a KDC — a distinction nothing in the bytes carries, and getting it wrong reported the TGS page’s own Authenticator as undecryptable while the page held the key. The TGS-REQ fixture is built bykrb5_client.jsrather than by hand, so the nesting is the nesting the pages actually produce. It also covers the one branch ofkrb5_describe.jsthat is not a Kerberos message: aNegotiateheader, Since 2026-08-17 it also covers the one branch ofkrb5_describe.jsthat is not a Kerberos message: aNegotiateheader, which is what somebody pastes after copying anAuthorizationline out of a capture. That path fixes a trap nothing else would have caught — a SPNEGO token begins0x60, which IS[APPLICATION 0], soidentify()answered first and called it a Kerberos message this build does not decode: true of the grammar and the wrong layer. The section checks the mechanism list is named rather than left as OIDs (including Microsoft’s mis-typed one, which every Windows client offers), that the bytes the mechListMIC covers are shown and begin0x30, that the mechToken is opened by the SAME describers a pasted AP-REQ goes through, that a bare NegTokenResp is recognised too, and that a malformed inner message is CONTENT rather than an exception — the negotiation around it is often what explains the breakage, so losing the document to a throw discards the useful half)krb5_as_exchange.js— moved to iya-sts’s own suite on 2026-10-23: it drove that service’s KDC in process.krb5_tgs_ap.js— moved to iya-sts’s own suite on 2026-10-23: it drove that service’s KDC and acceptor in process.api_krb5_relay.js(the address policy on a raw socket, the port allowlist, the payload pre-flight, the limits — and, since 2026-08-17, three deadlines rather than two. Its “every path settles” case failed a real run with the relay still resolving a name five seconds in: the connect and call budgets are both armed after resolution, so an unboundeddns.lookupwas bounded by nothing, which on the endpoint is a browser waiting forever. The timeout case cannot be provoked through the machine’s own resolver —dns.lookupis getaddrinfo and ignoresdns.setServers— sotheNameLookupIsOnADeadline()injects one that never calls back, and races rather than awaits, because a missing deadline otherwise fails by hanging the whole run instead of naming itself. The unresolvable-name case alongside it accepts either DNS failure, since which one a real resolver produces is a fact about the network rather than about the code)kerberos_decoder_page.jskerberos_as_page.js(and since 2026-08-17 the section that proves the Ticket pane shows what is INSIDE the ticket. An AS-REP carries two encrypted parts under two different keys: the enc-part, under the key the page derived from the password, and the TICKET, underkrbtgt’s — which no client ever holds. So the ticket’s ciphertext cannot be opened on that page at all, and the contents come from the KDC’s own copy of them in the reply’s enc-part. The section asserts the absence of a control as well as the content — no input, button, select or textarea anywhere in the pane, and nokrb_deckey_buttonon the page — because a pane that needs a click is what this replaced and it would pass every content assertion if the test happened to click it. Then: the section is titled as the KDC’s REPORT rather than as plaintext, it names the enc-part the values came from, all four times and the session key are there, the one field the KDC does not repeat (authorization-data, the PAC) is named as absent, and nothing sends the reader to another page. It re-checks the same thing after a reload, because the pane is fed from the stored entry rather than from the live reply — a version built off the reply passes until you press F5. It also has to put the page back: the reload leaves step 2 disabled (gated on step 1) and a ticket held, and the next section asserts a wrong password and an empty cache, so forgetting either fails a section that names the wrong thing entirely)kerberos_tgs_ap_page.jskerberos_delegation_page.js(and it had never run once, up to 2026-08-17. Its gate readlimits.kdcPortsfromGET /krb5/limits, a field the api has never emitted —api/server.jsanswers withallowedPorts— so the guard was!undefinedon every run and the whole file skipped, reporting “the api’s krb5AllowedPorts does not include 88 (it allows undefined)”. That names a plausible environment problem, which is what made it expensive: every section below it, including ones written for defects found by hand, had never executed, and two had rotted where nobody could see. It drove akrb_get_tgt_buttonand readkrb_status— the AS page has neither, and never did; it has two buttons (krb_noreauth_buttonthenkrb_preauth_button, because a service account’s SALT has to come from the KDC’sPREAUTH_REQUIREDfirst) and the line iskrb_as_status. And the storage opt-out section asserted an evidence ticket was inlocalStoragewhen saving is OFF by default for the whole workflow, so it could only ever have reported its own premise as unmet. All three are fixed and the file passes end to end. When it skips, check the field names against the api’s actual response before believing the environment. The section added with the hex tabs is the one that needs a browser most: it measures the computed contrast of the highlighted bytes (15.1:1, failing below 4.5) because the regression it exists for was a stylesheet — every class defined, every id right, the strip’s text still changing, andkrb-hex-onlosing its background to a resting tint of equal specificity, i.e. white on pale at 1.11:1. It also sweeps the pointer without ever clicking a cell, checks all five views of each exchange hold different byte strings, and checks that a REFUSED exchange empties the three per-part panes — the only hex panes in the workflow that can go stale, since they cannot be filled on the way past)kerberos_page_density.js(the six pages’ layout, and the only Kerberos browser job that needs no KDC. It loads each page at 1366x768 and requires the control that page exists for inside 640px — the house figure — asserting against the viewport as well, since headless Chrome has no toolbar and is the more generous of the two. Its value is what it asserts underneath the heights: the computed height of a pane legend, because bootstrap’slegend { line-height: 40px }had silently come back on every pane of every page when the panes moved to.dbg-paneand the override still said.krb-pane > legend; and the computed bottom margin of a.krb-field, becauseinput[type="text"]at (0,1,1) beats a bare class at (0,1,0) and was adding 6px to every input in the workflow. Neither made a page look wrong, which is why a height-only check would have been the wrong test. It also refuses the cheap fix: each page must still carry itsdetails.krb-morefolds, and none of them may start open, so a page cannot come inside the budget by deleting the explanations that are the reason to use it. All three assertions mutation-tested. Skips only where the other Kerberos page jobs do — on a static target, where the pages are not deployed)krb5_spnego_codec.js(RFC 4178, byte by byte —common/krb5/krb5_spnego.js. Node only, no services, never skips. Every expected value is derived by hand from the RFC and from the OIDs’ own registrations rather than from what the encoder produces, which is the standard of evidence a wire codec needs: a reader and a writer sharing one misunderstanding agree perfectly with each other and with nothing else. Two of the hand-derived expectations were wrong on the first run and the encoder was right, which is exactly what writing them that way is for. Four things earn it its keep. The OID coder, because SPNEGO is the first thing in this tree that has to write one — Kerberos’s own ASN.1 contains no OBJECT IDENTIFIER at all — and because Microsoft’s mis-typed Kerberos OID differs from the real one in a single arc, so a coder that dropped a continuation bit would turn one into the other. That the mechListMIC coversMechTypeListand NOT[0] MechTypeList: two bytes, RFC 4178 section 5, and the difference between a MIC that verifies and one that verifies against nothing while being perfectly well formed. The[3]ambiguity — a MIC in RFC 4178,negHintsin [MS-SPNG]’s NegTokenInit2, which displaces the MIC to[4]— told apart by what is inside rather than by the direction of travel, because the interesting captures are the ones where the direction is not what you assumed. And thatnegStateis ENUMERATED (0x0a) rather than INTEGER (0x02), which encode identically apart from the tag, so a coder using the wrong one round-trips against itself perfectly and is refused by a strict peer. It also checks that what is NOT SPNEGO is named: a bare Kerberos token, an unwrapped AP-REQ, another mechanism’s OID, and a NegTokenResp wrapped where a NegTokenInit belongs)krb5_spnego_http.js— moved to iya-sts’s own suite on 2026-10-23: it drove that service’s SPNEGO acceptor in process.kerberos_spnego_page.js(the SPNEGO page and the routing that feeds it.krb5_spnego_http.jsdrives the protocol far harder with no browser, so this covers only what a browser is needed for. The routing loop first, because it is the whole reason this is a workflow rather than a page: SPNEGO spends a service ticket and cannot obtain one, so it links out to the AS page and the TGS page with?return=spnegoand each of them offers a link back — and nothing about the protocol notices when that breaks. It walks the loop out and back through the banner’s own link, and it asserts the banner renders before a ticket exists, which is the case it is for (somebody arrives precisely because they have none) and the one an implementation puts after an early return and never renders — which happened here once. Then the credential handoff under a third reader ofkerberos_panes.js’s shared cache, where a rename is silent: the pane renders, the button stays disabled, and the page says “no service ticket held” for a ticket sitting in storage. Then the SPN the page GUESSES from the URL’s host, which nothing in the exchange carries — when it is wrong the failure is a KDC error three steps earlier naming nothing about HTTP, so the field must exist, be pre-filled and be overridable, and?spn=must beat whatever the TGS page used last. Then the panes: the decoded NegTokenInit and what its mechanism ORDER decides, the AP-REQ inside it, the 0x8003 checksum, the hex view naming a field AND its absolute offset under the pointer, and the ticket — opaque until a service key is supplied, and it must SAY so rather than render an empty pane, then open the EncTicketPart and the PAC once one is. Three negatives through the UI, each a deliberate misconfiguration the mock offers as a query parameter. Needs the client, the api and the mock STS; unlike the AP page it needs no extra api setting, and it skips with a named reason for each missing piece including an api or a mock that predates the workflow)kerberos_spnego_signin.js(Kerberos as a way of SIGNING IN, which is the door the page job above does not touch./spnego/protectedauthenticates a person and throws the identity away;/authn/spnegois the same handshake with the last step added, and past it there is a browser SESSION that/oauth2/authorize,wsignin1.0, a SAML AuthnRequest and/adminall read. Nothing in either repository drove it until 2026-08-27 — it shipped on 2026-08-26, was hand-verified once, and the driver was a scratch script nobody kept. Nine sections: the door is DISCOVERED from/spnego?format=json’ssignInResourcerather than spelled in the test; the application is registered withappAuthnMechanism: spnegoand an authorization request for it with no session is asserted to go STRAIGHT to the Kerberos door rather than to the password screen (an attribute nothing reads is a record of intent, not a configuration); the door’s bareWWW-Authenticate: Negotiate; the debugger’s own AS and TGS pages building a TGT and then a service ticket for the SPN the SPNEGO page DERIVED from the door’s host; the sign-in itself, asserted on all three of the “you are signed in” page, the subject with its realm stripped, and theSet-Cookie; a REPLAYED AP-REQ refused and minting nothing; and the payoff — the session cookie carried into the browser, an ordinary OIDC Authorization Code flow completing with no screen drawn, and the ID Token’ssub,amrof exactly["pwd"]andacrof"1"read off the ticket’s ownpre-authentflag. Thenkrb5.spnegoAuthenticationoff, asserted to answer 403 NAMING THE SETTING rather than 404, and reset in afinally. The browser does not answer the challenge and the file says so at length: GSSAPI needs a credential cache and an allow-list entry the suite cannot assume, so the debugger is the Kerberos client — which shows more of the protocol than a browser handing the work to GSSAPI would. The one seam is that theSet-Cookiereaches the api relay rather than Chrome, and one assignment carries it across. The replay, theamrderivation and the KDC’s own PA-ENC-TIMESTAMP were each mutation-tested against a deliberately broken mock before this was committed. Holds thests-spnego-signinJOB_LOCK with the two other ticket-spending Kerberos jobs — it turns a process-wide setting off, and it asserts on a replay cache that a concurrent job would disturb. Needs the client, the api and the mock STS; skips with a named reason for each missing piece, including a mock that predates the sign-in door)krb5_mit_client.js(the mock KDC driven by MIT Kerberos itself —kinit,klist,kvno,kdestroyandcurl --negotiate. Every other Kerberos job in this suite drives that KDC with a client this project wrote, and none of them can answer the question this one exists for: does any of it interoperate with a real Kerberos? The answer was NO for as long as the mock KDC had existed and nothing noticed — itsKDC_ERR_PREAUTH_REQUIREDlistedPA-ETYPE-INFO2andPA-PW-SALTand notPA-ENC-TIMESTAMP, so it named the salt a client needs without naming the METHOD it wanted;kinitdied withGeneric preauthentication failureand no browser could ever have signed in with Kerberos at all. Both ends of every existing test shared the assumption, so every test passed. Section 1 is the guard for that, and its failure message says so and quotes whatkinitprinted. Fourteen sections, NINE of them refusals, each asserted against the KDC’s own error rather than the client’s summary: both AS exchanges told apart by how many round trips they cost (the two-message dance foralice, and ZERO KDC errors fornoreauth, which needs no pre-authentication); an invented username created on first sight; a wrong password, a revoked account, an expired password, an RC4-only account a 2025 client will not negotiate with, and both reserved names; the TGS exchange and an SPN for a host the service will not BE; the bareNegotiatechallenge; SPNEGO at BOTH doors — the protected page, which must mint no session, and the sign-in, which must; the session then completing an OAuth flow; a replayed AP-REQ refused with a fresh one still working;kdestroyleaving nothing to authenticate with; andkrb5.spnegoAuthenticationoff answering 403 naming the setting.KRB5_TRACEgoes to a FILE and never to/dev/stderr— under captured stdio that path is a pipe and MIT writes nothing to it silently, so a test written against what a shell does reads every exchange as “the KDC returned no errors” and every refusal passes for the wrong reason. Theexpiredaccount is the case worth reading: the mock answersKDC_ERR_KEY_EXPIREDcorrectly, MIT then asks for akadmin/changepwticket the mock does not serve, and the LAST thingkinitprints names a missing service rather than the expiry — so that one reads the FIRST error. Writes its ownkrb5.confand credential cache under the system temp directory, so it needs no root and cannot disturb the machine’s own Kerberos. No browser and no api. Holds thests-spnego-signinJOB_LOCK. Skips with a named reason where MIT Kerberos is absent or curl was built without GSS-API;tests/Dockerfileinstallskrb5-userso the containerized suite always runs it)krb5_capture_real_dc.js(not a test — the RECORDER. It drives the same exchangekrb5_real_dc.jsasserts and writes every message intotests/captures/windows-server-2025.json, whichkrb5_windows_vectors.jsthen checks on every ordinary run../local-run-tests.sh --krb5-real-dc=captureruns it;=bothruns the test FIRST, because a capture taken from a broken run would be worse than none — the offline test would assert the breakage from then on)krb5_delegation_interop.js— its iya-sts half moved to iya-sts’s own suite on 2026-10-23; what is scheduled here is the real Windows KDC half alone, which drives this project’s Kerberos client. (S4U2Self and S4U2Proxy run twice, against two different KDCs — the mock and a real Windows DC — because that is the only way a divergence between them becomes visible, and a mock written from the same reading of [MS-SFU] as the client cannot reveal one on its own.KRB5_DELEG_TARGETselects; the mock half always runs, the Windows half skips withoutKRB5_DC_HOST)krb5_ranges_offsets.js(the byte ranges the hex tab colours with —common/krb5/krb5_ranges.js. One assertion carries it: slice the ORIGINAL buffer at every reported offset and the bytes must equal that element’s own. That check cannot pass on an offset that is relative to its parent, which is the specific bug being guarded —krb5_asn1.js’stree()recurses by re-parsing the value slice, so its offsets are relative, and a hex view built on them does not crash. It highlights the wrong field, plausibly, and somebody reads a value off the screen that belongs to something else. The mutation is in the test: it computes what a relative offset WOULD be and confirms the assertion above rejects it. Node only)krb5_field_naming.js(the NAMES on those bytes —common/krb5/krb5_field_names.js, and the wiring that puts the hex view on a page. Named for the module rather than after it because the tests image copies both flat into one directory and identical basenames mean the last COPY wins;jwk_pem_encoding.jscaught that pair. The assertion that carries it is name-to-bytes: read the buffer at the offsets of the range calledAS-REQ → req-body → cname → name-string[0]and it must decode to the principal that was encoded — a table with two fields transposed passes everything else and fails that, and a wrong name does not crash, it saystillwhile highlightingrtime. Beside it the drift check (every context tagkrb5_messages.jsemits across an AS-REQ, AS-REP, AP-REQ, KRB-ERROR, Ticket and GSS token must be named — 97 of them) and its own mutation: removenoncefrom the table and the drift check must notice, which it did not until an unrecognised tag stopped inheriting its parent’s name. The static half reads the pages — DISCOVERED by which of them linkcss/kerberos.css, not listed, which is how it already covers a sixth page that appeared while it was being written — and fails on a_hexdiv nothing fills, a tab strip nothing wires, and onrenderMessage()losing the one call that fills every hex tab in the workflow. Since the delegation page grew five-button strips it also fails on two panels sharing a tab name inside one group (selectTab()would open both, stacked, with both buttons lit), on a button with no panel, and on the reply-part convention losing a suffix or a key thatkrb5_client.jsno longer returns. Two of its checks were hollow when written and are worth remembering as a class: one searched the whole page for “a button calledhex_encpart” and was satisfied by a different pane’s button, and several asked whether a call was present in a bundle that describes its calls in prose — socodeOf()strips whole-line comments first, after commenting outpanes.wireTabs()left the check for it passing. It also asserts, statically, the one thing that made the highlight invisible: that the rule giving.krb-hex-ona background either carries two classes or is declared after every.krb-hex-s*tint. Node only)krb5_ticket_history.js(the workflow’s Ticket Cache & History pane — its storage layer, and the wiring on all six pages. A hundred rows, each holding a session key, which is why it is a credential store rather than a list. Node only: the pane needs a browser, what it stores does not. The assertion that matters is the opt-out — untickingkrb_save_ccachemust purge the whole list, andenforceStoragePreference()names each key it removes, so a key added later is forgotten there by default and the symptom is an opt-out that quietly keeps a hundred credentials. Next to it: the kind of a ticket is DERIVED fromkrbtgt/…and beats any label a caller passes, and each kind goes back into ITS OWN slot — a service ticket written into the TGT slot is accepted silently and fails a page later with an error naming an encryption type, soactivateTicket(index, kind)treats that second argument as a check and refuses rather than writing. The wiring half is static and for the same reasonkrb5_operation_history.js’s is: all six pages must includepartials/krb_tickets.htmlandmount()it exactly once (include without mount is an empty pane, indistinguishable from a workflow that has obtained no tickets), each page must mount only the slots it actually holds — the AP page cannot take back a TGT and the AS page cannot take back a service ticket — and the DECODER must mount itreadOnly, because an activation writes a ticket and its session key to storage andpersistsNothing()inkerberos_decoder_page.jsasserts that page never writes anything. Four mutations, all caught. Never skipped)krb5_operation_history.js(the Kerberos Operations History pane, and the half of it a browser could not check. The pane is the fourth log overclient/src/op_history.jsand appears on all six pages; a row is opened when an operation starts and closed bypanes.status()writing to the status line that operation named — from there rather than from each handler, becauseonS4u2Self()alone has fivereturn falsepaths before its request is even built. The behaviour half is ordinary: opensSent, closes on a terminal class, four operations on four status lines settle independently,krb-warnis a Success (it is an exchange that worked and produced a ticket with an unwelcome property, and reading it as a failure says the KDC refused a request it granted). The half worth having is static, because the failure it catches is invisible at runtime: a page that opens a row against a line it never writes a terminal status to leaves every one of those operations readingSent, which is a legitimate value meaning “nothing came back” — the pane renders, the row is there, and no assertion anywhere would fire. So per bundle it checks that every line named has both a success and a failure path; that all six pages includepartials/krb_history.htmlandmount()it against the partial’s own ids (include without mount is an empty div, indistinguishable from a workflow that has done nothing); that every operation label is actually recorded by some page; and that the classesop_history.jsemits under this workflow’skrb-opprefix are all defined incss/kerberos.css, its defaults being thesaml-*ones these pages never load. Seven mutations, all caught — one of them a tautology in this test’s own clipping assertion, which compared the clipped length against the constant that produced it and so could never fail. Two sections were added on 2026-08-17 for a defect reported off a real row,[object Object]in the User / principal column: one drivesbegin()andnote()with a parsed principal and requires the name back (the log normalizes what it is handed, which is the half that covers a page nobody has written yet), and one checks statically that no bundle hands one over in the first place — the variables to look at are discovered from whatever a page assigns out of arevive*()call, since.clientread off the store is text and must not be flagged. Two things about that static check are worth carrying, both found by mutation: a filter looking only forprincipal:missed the delegation page entirely, because it calls its ownbeginDelegationOperation(op, statusId, principal, detail)positionally — four of the eleven rows, including the one the report came from; and matching onlyrevive(missed the TGS page, whose reviver is calledreviveTgt(). A.clientused as a truthiness guard is exempt, which is howspnego.jslegitimately writes it. Node only, never skipped)static_site_exclusions.js(what the static deployments leave out —client/static_site.js. Kerberos is DER over port 88, so the whole workflow goes through the api’s relay andidptools.comhas no api: all six pages are dropped fromdist/and both of their landing cards are greyed out. Every part of that fails silently when it drifts, which is why this is a job rather than a comment —fs.rmSyncon a renamed path SUCCEEDS, so an exclusion can quietly stop excluding; a card whosedata-not-on-staticmarker stopped matching stays a live link to a page the build just deleted; and a surviving page linking to a dropped one is a 404 nothing clicks. It also checks the two halves of the card’s description swap rather than stack (a row is as tall as its tallest card); that the note under the grid — the one thing on that page which appears ONLY on a static deployment, hidden bydisplay:noneeverywhere else, so losing it is invisible on every build anybody develops against — is there, names the containerized build and links to the repository, and is revealed by the same rewrite that kills the cards; and, in the other direction, thatclient/Dockerfilestill browserifies all six — the exclusion is static-only and the container is where the workflow runs. Six mutations, all caught. Node only, no services; the two directory-wide checks say in the log whenclient/publicis absent, which is the tests image, whereclient/build.jsruns the same two overdist/on every deploy)page_load_retry.js(the page-load guard intests/page_load.js, against a socket that behaves like a CDN edge on a bad day — no api, no client, no STS: it opens its own targets on loopback, so it is never skipped.driver.get()does not tell you the navigation worked. Some failures it reports by THROWING (net::ERR_CONNECTION_REFUSED— a connection never established); the ones that matter here it does not report at all — when the connection IS established and then dropped (ERR_EMPTY_RESPONSE,ERR_CONNECTION_RESET, the shapes an edge produces once in a few hundred requests)get()resolves,getCurrentUrl()returns the URL that was ASKED FOR, and the tab holds Chromium’s error page. The test then waits out its whole budget for a field that was never there and fails naming one of OUR ids, which isWS-Trust 1.2 — Issueon 2026-08-15 (test.idptools.com) andWS-Trust 1.4 — Validateon 2026-08-20 (idptools.com), both with the neighbouring cases loading the same page seconds either side. Seven checks — four onloadPage()and three onloadUrl(), the second entry point ~180 call sites now navigate through. A dropped connection is retried and the load succeeds; a target that keeps dropping fails naming the CODE (.error-codein the DOM — the first 6000 characters of that page are Chromium’s stylesheet, which is what both failed runs logged instead of a reason); a page that IS ours with the field missing fails on the FIRST attempt, since retrying a product failure only trebles the time it takes to report; and a refused connection — the throwing half — is reported with its code too. TheloadUrl()three are the same dropped connection with no id to wait for, the same refusal named by code, and the one that makes the sweep safe: a page that is NOT ours is handed straight back, on the first attempt and without spending a timeout — it judges no content, so there is nothing there for it to retry or to wait on. The first check has a trap worth knowing: Chrome re-sends a dropped GET itself, two or three times per navigation, so a single dropped connection is recovered by the browser before the helper sees anything and a check counting the target’s requests passes with the retry deleted. It asserts on the attempt countloadPage()returns, against a target that outlasts Chrome’s own. Both halves mutation-tested — retry removed fails A, retry made unconditional fails C)browser_tests_headless.js(every test here that builds a Selenium driver starts Chrome headless, and does it by default — a check on this suite rather than on the app. It reads its own directory: a file withnew Builder()and.forBrowser()must pass--headlessthrough anaddArguments()call, and if it keeps the customaryheadlessvariable so a human can watch a run, that variable must be initializedtrue. An opt-out is fine; an opt-in is what this refuses, because every unattended run then opens windows. The mistake is a copy-paste one and it is silent in exactly the wrong way:browserFlags.addBrowserAccessFlags(options, baseUrl)looks like it configures the browser and does not — it covers the secure-context and private-network hazards and says nothing about headless mode — sokerberos_delegation_page.jswas written with the flags and without the flag and popped a window on every run for as long as it existed. On a desktop that steals focus for the length of the run; on a CI runner or in a container there is no display at all, so the session dies atsession not creatednaming the page the test was about to visit. It reads STATEMENTS rather than lines (the flag list is routinely wrapped across three of them at 80 columns), refuses to pass vacuously if the scan finds fewer than 25 driver-building tests, and counts — without failing — the fifteen older tests still using bare--headless, which in the Chrome 121 the image pinned until 2026-09-03 is the OLD headless mode where--unsafely-treat-insecure-origin-as-securehas no effect — on the 152 pinned now the two spellings are the same mode, so that count is history rather than exposure. Both checks mutation-tested. Node only, no services, never skipped)download_dir_pinned.js(no browser test may leave a file in the developer’s home directory — the second check on this suite rather than on the app, and the sibling of the one above. Several pages here have a Download button, and a test that clicks one writes a real file; on a host run (./local-run-tests.sh,./remote-run-tests.sh) the browser is the developer’s own Chrome and its default directory is~/Downloads. Nothing fails when that happens: the assertion those tests make is on the page’s own status line, which readsDownloaded ...wherever the file went, so the run is green and the only evidence is somebody’s home directory filling up with key material — by 2026-09-03 that was 71 copies each ofml-kem-keys (N).pemandecies-keys.jwk (N).json, one pair per run since 2026-08-27, beside the keystore matrices from before the first fix. It was fixed once already, per test, which is why it came back:jwt_tools.jsanddigital_signature.jseach grew a throwaway temp directory, four Chrome user preferences and a CDP call of their own, andencryption_tools.jsthen arrived with a key-download section built on the sameclient/src/key_material.jsand no reason for anybody writing it to know those four lines existed. The rule now lives in section (6) ofbrowser_flags.jsand is applied fromaddBrowserAccessFlags()andaddStsTrustFlags()— the two functions all 73 driver-building tests already call — so a new test gets it without knowing it exists. This holds that arrangement to four things: both of those functions still apply it; every test that builds a Chrome driver still passes through one of them;browser_flags.jsis still the ONLY caller ofsetUserPreferences(), which replaces the preference map rather than merging into it, so a second caller silently drops the download directory; and each of the three tests that actually clicks a Download button also callspinDownloadDir(driver). It then builds a realchrome.Optionsthrough each door and asserts the directory that lands on it is underos.tmpdir(), exists, and is not inside the home directory. Node only, no services, never skipped)url_safety_schemes.jswstrust.jswstrust_operation_history.jswebauthn_cross_impl.jswebauthn_extension.jswebauthn_oidc_mfa.jswstrust_schema_validate.jsxml_parse_inert.jsxmlsec_interop.js-
Since 2026-08-24 it also covers the general XML Signature engine behind the Digital Signature page’s XML pane — the same file’s signXml()/verifyXml(), which leave open every choice the two shaped signers above fix. Every RSA SignatureMethod (and, where xml-crypto’s own table can read it, verified by xml-crypto); all three signature types, including the detached case in both of its forms — a document that already has an ID says nothing, one without gets an ID and the engine REPORTS it, because the document that verifies is then the one it returned rather than the one it was handed; a modified element and a modified ATTRIBUTE; the four canonicalization methods, with the WithComments halves asserted to produce different digests over a sample that contains a comment and the omit-comments half asserted to digest a commented document exactly as an uncommented one; every DigestMethod; ECDSA and HMAC driven by node’s own OpenSSL through the injected signer/verifier, which is the whole reason that injection exists — plus the assertion that the SignatureValue is the 64-byte RS and not a DER SEQUENCE; the base64 transform digesting the DECODED octets; RSAKeyValue and KeyName KeyInfo, and no KeyInfo at all; an InclusiveNamespaces PrefixList; and five refusals that each name what is wrong — a transform after a canonicalization, malformed XML, an unknown SignatureMethod, an unknown DigestMethod, and a Reference that resolves to nothing. A missing enveloped-signature transform is added and reported rather than producing a signature that can never verify. The two XPath transforms are NOT driven here and the test says why: they are the DOM’s, and @xmldom has no document.evaluate— what is asserted is that the refusal names it, andtests/digital_signature.jsruns them in a browser. A further section covers whatapi/server.jssigns, which since 2026-08-24 is this same module rather than thexml-cryptopackage: an AuthnRequest signed with each of the four RSA SignatureMethods and handed to xml-crypto, the<Signature>asserted to sit directly after<Issuer>where the SAML schema requires it, a tampered copy refused, KeyInfo omitted rather than emitted empty when there is no certificate — and the redirect binding’s detached signature checked with node’s OpenSSL under the digest itsSigAlgnames AND refused under a different one, which is the mutation check that keeps that assertion from going vacuous. That last pair pins a real defect: the api signed withcrypto.createSign('RSA-SHA256')whatever SigAlg it advertised, so a request declaring rsa-sha512 was signed with SHA-256 and nothing local could see it
-