Writing scripts in Groovy
This page provides practical patterns and examples for Groovy scripts used in the ScriptState. For the complete list of bound objects and their API signatures, see Bound objects.
Script file setup
External script files are referenced with the file:// prefix. The .gy and .groovy extensions are both conventional.
<property name="script" value="file:///var/opt/nevisauth/default/conf/myScript.groovy"/>
Scripts can also be embedded directly as the script property value, but this is discouraged for anything beyond a single statement — line breaks in XML property values are collapsed to spaces during parsing, which breaks multi-line scripts.
Parameters and EL variable substitution
Parameters are defined in the AuthState configuration with the parameter. prefix and accessed in the script via parameters.get(name):
<property name="parameter.baseUrl" value="https://api.siven.ch"/>
<property name="parameter.maxRetries" value="3"/>
def baseUrl = parameters.get('baseUrl')
def maxRetries = Integer.parseInt(parameters.get('maxRetries'))
Parameter values support EL variable substitution. Substitution is resolved per-request before the script runs, so parameters can reference live session data, notes, or inargs:
<property name="parameter.userId" value="${session:ch.nevis.idm.User.extId}"/>
Logging
The LOG binding is an SLF4J Logger scoped to the trace group set by scriptTraceGroup (default: Script). Use parameterised format strings with {} placeholders to avoid string concatenation overhead.
LOG.debug('Processing request for user: {}', request.getLoginId())
LOG.info('Session auth level: {}', session['ch.nevis.session.authlevel'])
LOG.warn('Unexpected method: {}', request.getMethod())
try {
// ... external call ...
} catch (Exception e) {
LOG.error('Call failed: {}', e.getMessage(), e) // logs message + full stack trace
response.setError(1, 'call.failed')
response.setResult('error')
}
Log output is only visible when the matching level is configured for the trace group in the nevisAuth log configuration:
<category name="Script">
<priority value="DEBUG"/>
</category>
Replace Script with the value of scriptTraceGroup if you configured a custom one.
Reading user input
User-submitted form fields are available via the inargs binding (java.util.Properties). Treat inargs as read-only.
def username = inargs.getProperty('isiwebuserid') // explicit Properties call
def password = inargs['isiwebpasswd'] // Groovy map shorthand
def isSubmit = inargs.containsKey('submit') // check if field was sent
inargs is the same object returned by request.getInArgs().
Accessing HTTP headers and request context
nevisProxy forwards HTTP headers to nevisAuth as login context properties with the prefix connection.HttpHeader.:
def referer = request.getLoginContext().get('connection.HttpHeader.Referer') ?: '/'
def userAgent = request.getClientType() // HTTP User-Agent
def language = request.getLanguage() // negotiated language code (e.g. 'en')
For a specific header, use the shorthand method:
def referer = request.getHttpHeader('Referer')
To access the URLs involved in authentication:
def originalResource = request.getResource() // what the user originally requested
def currentAuthUrl = request.getCurrentResource() // the current authentication request URL
Controlling the authentication flow
Call response.setResult(String) to select the next state. The value must match a ResultCond name configured on the AuthState.
response.setResult('ok') // proceed to the 'ok' transition
response.setResult('error') // proceed to the 'error' transition
To signal a validation error and re-display the same form, set an error before looping back:
response.setError(1, 'login.error') // sets lasterror=1, lasterrorinfo=login.error
response.setResult('default') // loop back to self via a ResultCond named 'default'
The error values are exposed in GUI templates as ${notes:lasterror} (code) and ${notes:lasterrorinfo} (message).
If setResult() is never called, the response status remains AUTH_CONTINUE and nevisProxy will re-render the current GUI. This is the intended pattern for the first call to a form-based script — the script runs, finds no submitted data, and returns early.
Authentication: identity and level
Set the user's identity after successful credential validation:
response.setLoginId(username) // login name as typed by the user
response.setUserId(username) // internal user identifier (may differ)
response.setAuthLevel('auth.weak') // or 'auth.strong', or a numeric level like '2'
response.setResult('ok')
Manage security roles:
response.addActualRole('administrator')
response.addActualRole('2') // numeric levels are also stored as roles
response.removeActualRole('viewer')
String[] roles = response.getActualRoles()
Read back the identity accumulated so far in the flow from request (set by earlier states):
def currentUser = request.getUserId()
def currentLevel = request.getAuthLevel()
Method-based branching (authenticate vs. step-up)
If you deliberately reuse the same script across multiple authentication phases (for example by pointing both an initial login state and a step-up state at the same .groovy file), use request.getMethod() to branch logic accordingly.
def method = request.getMethod()
if (method == 'authenticate') {
def userId = inargs.getProperty('userId')
response.setUserId(userId)
response.setLoginId(userId)
response.setAuthLevel('auth.weak')
response.setResult('ok')
}
else if (method == 'stepup') {
// optionally elevate or change user identity during step-up
def newUserId = inargs.getProperty('changeUserIdTo')
if (newUserId != null) {
response.setUserId(newUserId)
}
response.setResult('ok')
}
Common method values:
authenticate— initial authenticationstepup— step-up authentication (elevating an existing session to a higher auth level)
Session management
The session binding is a lazy Map<String, String> proxy over the user's session data. For a reference of standard ch.nevis.session.* attribute names, see Session attributes.
Reading: Safe to call even when no session exists — returns null or an empty map without creating a session.
Writing: The operations put, putAll, putIfAbsent, replace, computeIfAbsent, computeIfPresent, compute, and merge automatically create the session if it does not exist yet.
// Write — creates the session if needed
session['ch.nevis.session.authlevel'] = 'auth.strong'
session.put('myapp.userId', userId)
session.putIfAbsent('myapp.firstSeen', Instant.now().toString())
// Read — safe even without a session
def authlevel = session['ch.nevis.session.authlevel']
def existing = session.get('myapp.userId')
// Check
if (session.containsKey('myapp.userId')) {
// ...
}
StringGroovy's dynamic typing allows non-String values to be placed in the session map without an immediate compile error. nevisAuth validates all session attributes after the script finishes and throws an AuthStateException if any attribute value is not a String.
Storing complex data in the session
To store complex data, serialise it to JSON first:
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
// Store a map as a JSON string
def data = [token1: 'abc', token2: 'def']
session['myapp.tokens'] = JsonOutput.toJson(data)
// Restore the map in a later state
def restored = new JsonSlurper().parseText(session['myapp.tokens'])
LOG.debug('token1: {}', restored.token1)
Notes: passing data within a request
Notes are a java.util.Properties store shared within a single authentication request. Unlike session data, notes are not persisted across requests.
// Write
notes.setProperty('myapp.resource', request.getCurrentResource())
notes['loginid'] = inargs.getProperty('isiwebuserid') // Groovy shorthand
// Read
def remembered = notes.getProperty('loginid')
// Check
if (notes.containsKey('lasterrorinfo')) {
response.addErrorGuiField('lasterror', notes['lasterrorinfo'], notes['lasterror'])
}
Standard notes written by nevisAuth itself (e.g. lasterror, lasterrorinfo) can be read and overwritten from scripts.
Outargs and transfer
Output arguments (outargs) are key-value pairs propagated back to nevisProxy with every response.
Redirect transfer
To redirect the user's browser to an external destination through nevisProxy:
// Using the outargs binding directly
outargs.put('nevis.transfer.type', 'redirect')
outargs.put('nevis.transfer.destination', 'https://siven.ch/target')
// Or equivalently using response methods
response.setTransferDestination('https://siven.ch/target')
response.setIsRedirectTransfer(true)
Form POST transfer
To transfer the user to an external target via a self-submitting form POST (for example a SAML ACS endpoint):
response.setTransferDestination('https://siven.ch/saml/acs')
response.setIsRedirectTransfer(false)
response.addTransferField('SAMLResponse', encodedSamlResponse)
response.addTransferField('RelayState', relayState)
Propagating tokens to nevisProxy
Outargs prefixed with token. are commonly used to pass signed tokens to nevisProxy:
response.addOutArg('token.myToken', tokenValue)
Building a GUI dynamically
A script can build its own GUI at runtime instead of relying on the <Response> XML template. This is useful when the fields depend on session data or the result of an external call.
// First call: no form submitted yet — show the form
if (!inargs.containsKey('accept') && !inargs.containsKey('reject')) {
def termsId = session.get('myapp.pendingTermsId') // loaded from session or external call
response.setGuiName('TermsAcceptance')
response.setGuiLabel('Terms and Conditions')
response.addInfoGuiField('info', 'Please review and accept the following terms:', null)
response.addHiddenGuiField('termsExtId', '', termsId)
response.addButtonGuiField('reject', 'Reject', 'true')
response.addButtonGuiField('accept', 'Accept', 'true')
return // returning without setResult() keeps status as AUTH_CONTINUE and re-renders the GUI
}
// Form was submitted
if (inargs.containsKey('accept')) {
response.setResult('ok')
} else {
response.setResult('rejected')
}
Available GUI field builder methods:
| Method | Renders as |
|---|---|
addTextGuiField(name, label, value) | Text input |
addPasswordGuiField(name, label, value) | Password input |
addHiddenGuiField(name, label, value) | Hidden field |
addButtonGuiField(name, label, value) | Submit button |
addButtonGuiField(name, label, value, cssClass, icon, iconCssClass) | Button with custom styling |
addErrorGuiField(name, label, value) | Error message display |
addInfoGuiField(name, label, value) | Informational text display |
addRadioGuiField(name, label, value) | Radio button |
addCheckBoxGuiField(name, label, value) | Checkbox |
Not calling setResult() leaves the response status as AUTH_CONTINUE. When setGuiName() has been called, the dynamically added fields are used as the GUI descriptor. When neither is called, nevisAuth falls back to the <Response> template in the AuthState configuration.
Setting cookies
Use response.setCookie() to instruct nevisProxy to set a cookie on the user agent. The cookie is not sent directly — nevisProxy adds it to its response.
// Set a session cookie (expires when browser closes)
response.setCookie(
'myapp.session', // name
tokenValue, // value
'/', // path
null, // domain (null = use request domain)
Duration.ofSeconds(-1), // maxAge: negative suppresses the Max-Age header → session cookie
true, // secure (HTTPS only)
true // httpOnly (not accessible to JavaScript)
)
// Set a persistent cookie expiring in 30 days
response.setCookie(
'myapp.rememberMe',
tokenValue,
'/',
null,
Duration.ofDays(30),
true,
true
)
// Expire (remove) a cookie
response.removeCookie('myapp.rememberMe') // sets value to "0" with no Max-Age; browser drops the cookie
Cookie values must not contain special characters without encoding. Encode the value with URLEncoder.encode(value, 'UTF-8') if needed, and decode on the receiving end.
Direct HTTP responses
A script can return a raw HTTP response (for example a JSON error body) directly to the user agent, bypassing nevisProxy's normal login page rendering.
import groovy.json.JsonOutput
def payload = JsonOutput.toJson([status: 'error', message: 'Invalid token'])
response.setContent(payload)
response.setContentType('application/json')
response.setHttpStatusCode(400)
response.setHeader('Cache-Control', 'no-store')
response.setIsDirectResponse(true)
// setResult() still controls which AuthState comes next in the flow
response.setResult('error')
Out-of-context data (OOCD)
The oocd binding provides persistent key-value storage across requests and sessions. Each entry carries an explicit expiration time. For background and configuration, see Shared out-of-context data.
// Store a value that expires in 24 hours
def expiry = Instant.now().plus(1, ChronoUnit.DAYS)
oocd.set('myapp.pendingToken', tokenValue, expiry)
// Store multiple values sharing the same expiry
oocd.set(['myapp.step': 'verify', 'myapp.userId': userId], expiry)
// Retrieve
def token = oocd.get('myapp.pendingToken')
if (token == null) {
response.setError(1, 'token.expired')
response.setResult('error')
return
}
// Read all entries with a common prefix
def allEntries = oocd.getWithPrefix('myapp.')
// Remove when done
oocd.remove('myapp.pendingToken')
oocd.removeWithPrefix('myapp.')
Date handling
The following java.time types are auto-imported in Groovy scripts when addAutoImports is true (the default):
DateTimeFormatter, Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime, ZoneOffset, ZoneId, ChronoUnit, Duration
Common date operations:
// Current timestamp
def now = Instant.now()
// Parse ISO-8601
def ts = Instant.parse('2025-04-26T09:33:40.459Z')
// Parse a custom format
def ts2 = Instant.from(DateTimeFormatter.ofPattern('yyyyMMddHHmmssX').parse('20250426093340Z'))
// Parse an offset date-time
def odt = OffsetDateTime.parse('2025-04-26T11:33:40.459+02:00')
// Arithmetic
def inTenDays = Instant.now().plus(10, ChronoUnit.DAYS)
def tenSecsAgo = Instant.now().minusSeconds(10)
def diff = Duration.between(Instant.now(), inTenDays)
// Comparison
def isExpired = Instant.now().isAfter(expiry)
def isBefore = Instant.now().isBefore(deadline)
Standard date format patterns are documented in the Java DateTimeFormatter reference.
Visit Date Handling Changes for migration details and additional examples.
Importing libraries and other scripts
To use a third-party library, place its JAR in the lib folder of the nevisAuth instance. Then import the class in the script:
import org.apache.commons.collections4.Bag
import org.apache.commons.collections4.bag.HashBag
Bag<String> bag = new HashBag<>()
To include another script file:
evaluate(new File('/var/opt/nevisauth/default/conf/OtherScript.gy'))
The included script file's name must conform to Java class naming rules (start with a letter, no hyphens or spaces).
HTTP client
See the HTTP Client documentation for all available client types and configuration options.
All examples below use the following AuthState configuration as a baseline, showing how to supply the target URL and HTTP client settings as script parameters:
<AuthState name="HttpScriptState" class="ch.nevis.esauth.auth.states.scripting.ScriptState" final="false">
<ResultCond name="error" next="AuthErrorGui"/>
<ResultCond name="ok" next="AuthDone"/>
<property name="parameter.url" value="https://siven.ch/api"/>
<property name="parameter.httpclient.tls.keyObjectRef" value="DefaultKeyStore"/>
<property name="parameter.httpclient.tls.trustStoreRef" value="DefaultTrustStore"/>
<property name="parameter.json" value="{ "attribute": "value" }"/>
<property name="script" value="file:///<path>/httpScript.groovy"/>
</AuthState>
Client lifecycle
The HttpClient in a ScriptState can be created with three different lifecycles. The per-ScriptState instance approach is recommended for most cases.
Do not import HttpClients when using HttpClients.create() without arguments — the HttpClients name is already bound as a special ScriptState binding, and importing it would shadow the binding and cause a runtime error.
Per ScriptState instance (recommended)
HttpClients.create() (no arguments) returns a cached HttpClient for the current ScriptState instance. The same client is reused across all requests to this state, enabling connection pooling. Client settings are taken automatically from parameter.httpclient.*.
def httpClient = HttpClients.create()
Per request
Passing parameters to HttpClients.create() creates a new client on every request. Connection pooling benefits are lost. Use a try-with-resources block to close the client after use.
try (def httpClient = HttpClients.create(parameters)) {
// ...
}
Per nevisAuth instance (global)
Calling send() without providing an HttpClient uses the shared nevisAuth global HTTP client.
def httpResponse = Http.get().url(url).build().send()
HttpClients.create() cachingparameter.httpclient.* values are EL-resolved on every request, but HttpClients.create() (no arguments) caches the HttpClient after its first creation for the lifetime of the ScriptState instance. The client is therefore built from the first request's resolved values and will not reflect EL expressions that change per-request (e.g. ${session:someValue}). Avoid using per-request EL expressions in parameter.httpclient.* properties when using the cached per-ScriptState lifecycle.
GET request
import groovy.json.JsonSlurper
def url = parameters.get('url')
try {
def httpClient = HttpClients.create()
def httpResponse = Http.get().url(url).build().send(httpClient)
LOG.info('Response status: {}', httpResponse.code())
if (httpResponse.code() == 200) {
def body = new JsonSlurper().parseText(httpResponse.bodyAsString())
// access parsed fields directly
def userId = body.userId
def status = body.status
LOG.debug('Received userId={} status={}', userId, status)
session['myapp.userId'] = userId.toString()
response.setResult('ok')
} else {
LOG.error('Unexpected HTTP response code: {}', httpResponse.code())
response.setError(1, 'Unexpected HTTP response')
response.setResult('error')
}
} catch (all) {
LOG.error('HTTP call failed: {}', all.getMessage(), all)
response.setError(1, 'Exception during HTTP call')
response.setResult('error')
}
POST request
def url = parameters.get('url')
def payload = parameters.get('json')
try {
def httpClient = HttpClients.create()
def httpResponse = Http.post()
.url(url)
.header('Accept', 'application/json')
.entity(Http.entity()
.content(payload)
.contentType('application/json')
.charset('utf-8')
.build())
.build()
.send(httpClient)
LOG.info('Response status: {}', httpResponse.code())
LOG.info('Response body: {}', httpResponse.bodyAsString())
if (httpResponse.code() == 200) {
response.setResult('ok')
} else {
LOG.error('Unexpected HTTP response code: {}', httpResponse.code())
response.setError(1, 'Unexpected HTTP response')
response.setResult('error')
}
} catch (all) {
LOG.error('HTTP call failed: {}', all.getMessage(), all)
response.setError(1, 'Exception during HTTP call')
response.setResult('error')
}
PATCH request
This example uses the global HTTP client by calling send() without passing an explicit client.
def url = parameters.get('url')
def payload = parameters.get('json')
def httpResponse = Http.patch()
.url(url)
.header('Accept', 'application/json')
.entity(Http.entity()
.content(payload)
.contentType('application/json')
.charset('utf-8')
.build())
.build()
.send() // uses the nevisAuth global HTTP client
LOG.info('Response status: {}', httpResponse.code())
LOG.info('Response body: {}', httpResponse.bodyAsString())
DELETE request
This example uses a per-request client. The body is read with a non-default charset.
import java.nio.charset.StandardCharsets
def url = parameters.get('url')
try (def httpClient = HttpClients.create(parameters)) {
def httpResponse = Http.delete()
.url(url)
.header('Accept', 'application/json')
.build()
.send(httpClient)
LOG.info('Response status: {}', httpResponse.code())
httpResponse.body().ifPresent(bodyArray ->
LOG.info('Response body: {}', new String(bodyArray, StandardCharsets.ISO_8859_1))
)
if (httpResponse.code() == 200) {
response.setResult('ok')
} else {
response.setError(1, 'Unexpected HTTP response')
response.setResult('error')
}
}
Building a URI
For simple query parameters, use HttpRequestBuilder#url(String) combined with HttpRequestBuilder#urlParameter(String, String). For complex URI construction, use URIBuilder:
import ch.nevis.esauth.util.httpclient.api.uri.URIBuilder
import java.util.Collections
URI uri = URIBuilder.http().localhost()
.port(443)
.path('/api/resource')
.parameter('key', 'value')
.parameters(Collections.singletonMap('other', 'param'))
.build()
LOG.info('Built URI: {}', uri.toString())
For detailed documentation, see the HTTP Client API documentation.
nevisIDM REST API access
The nevisIDM REST API can be accessed via the IdmRestClient from ScriptStates.
Full example: simple login script
This example asks for a username, validates it against a configured parameter, sets the user identity, and transitions based on the result. It demonstrates inargs, parameters, notes, LOG, and the core response methods.
<AuthState name="LoginScript" class="ch.nevis.esauth.auth.states.scripting.ScriptState" final="true">
<ResultCond name="ok" next="AuthDone"/>
<ResultCond name="default" next="LoginScript"/>
<Response value="AUTH_CONTINUE">
<Gui name="LoginScriptStateGui">
<GuiElem name="lasterror" type="error" label="${notes:lasterrorinfo}" value="${notes:lasterror}"/>
<GuiElem name="info" type="info" label="To log in, use the username: DummyUser"/>
<GuiElem name="input" type="text" label="Username"/>
<GuiElem name="submit" type="button" label="submit.button.label" value="Continue"/>
</Gui>
</Response>
<property name="script" value="file:///var/opt/nevisauth/default/conf/login.groovy"/>
<property name="scriptTraceGroup" value="Script"/>
<property name="parameter.environment" value="dev"/>
</AuthState>
String loginId = inargs.getProperty('input')
String environment = parameters.get('environment')
// First call — form not yet submitted; return to let AUTH_CONTINUE render the GUI
if (loginId == null) {
return
}
response.setLoginId(loginId)
if (loginId == 'DummyUser' && environment == 'dev') {
LOG.debug("User '{}' logged in at {}", loginId, Instant.now())
response.setUserId(loginId)
response.setAuthLevel('auth.weak')
response.setResult('ok')
} else {
if (environment == 'dev') {
response.setError(1, 'login.error')
} else {
notes.setProperty('lasterrorinfo', 'Cannot login: environment is not dev.')
}
LOG.debug("Login denied for user '{}' (environment: '{}')", loginId, environment)
response.setResult('default')
}
Testing
The AuthStateHarness from the nevisauth-test-authstateharness-fat artifact can run a ScriptState in a fully wired in-process environment, allowing you to write JUnit tests that exercise the actual Groovy script without deploying nevisAuth.
For background on the test harness, how to obtain it, and general testing practices, see the Testing AuthStates developer guide.
Basic test structure
The following example tests the login script from the Full example section above. It covers the success path and the error path.
import ch.nevis.esauth.auth.engine.PropagationScope;
import ch.nevis.esauth.auth.states.scripting.ScriptState;
import ch.nevis.esauth.test.AuthStateHarness;
import ch.nevis.esauth.test.TestContext;
import org.junit.jupiter.api.Test;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LoginScriptTest {
@Test
void successfulLoginSetsUserIdAndAuthLevel() throws Exception {
// Arrange: configure the ScriptState
Properties props = new Properties();
props.setProperty("script", "file:///var/opt/nevisauth/default/conf/login.groovy");
props.setProperty("parameter.environment", "dev");
AuthStateHarness state = new AuthStateHarness(ScriptState.class);
state.configureState(props);
// Arrange: supply form input
TestContext context = TestContext.of();
context.addFormParameter("input", "DummyUser");
// Act
state.process(context);
// Assert
context.assertNoError();
context.assertUserId("DummyUser");
context.assertAuthLevel("auth.weak");
context.assertResult("ok");
}
@Test
void wrongUsernameShowsErrorAndLoopsBack() throws Exception {
Properties props = new Properties();
props.setProperty("script", "file:///var/opt/nevisauth/default/conf/login.groovy");
props.setProperty("parameter.environment", "dev");
AuthStateHarness state = new AuthStateHarness(ScriptState.class);
state.configureState(props);
TestContext context = TestContext.of();
context.addFormParameter("input", "UnknownUser");
state.process(context);
// response.setError() was called — assertNoError() would fail here
context.assertResult("default");
assertEquals("login.error", context.getErrorDetail());
}
@Test
void brokenScriptThrowsException() throws Exception {
Properties props = new Properties();
props.setProperty("script", "this is not valid groovy }{");
AuthStateHarness state = new AuthStateHarness(ScriptState.class);
state.configureState(props);
TestContext context = TestContext.of();
state.process(context);
context.assertException();
context.assertExceptionStackContains("Failed to evaluate script");
}
}
Key TestContext methods
| Method | Description |
|---|---|
TestContext.of() | Creates a fresh context with no session, no inargs, and no notes. |
context.addFormParameter(String name, String value) | Sets an inarg (form field) as if submitted by the user. |
context.getContext().getInArgs().put(String name, String value) | Alternative for setting inargs directly on the AuthContext. |
context.getContext().setNote(String name, String value) | Pre-populates a note before the script runs. |
state.process(context) | Executes the ScriptState script. |
context.assertNoError() | Fails the test if an exception was thrown or if response.setError() was called. |
context.assertException() | Fails the test if no exception was thrown. |
context.assertExceptionStackContains(String text) | Asserts the exception message contains the given text. |
context.assertResult(String result) | Asserts the result condition set by response.setResult(). |
context.assertUserId(String userId) | Asserts the user ID set by response.setUserId(). |
context.assertAuthLevel(String level) | Asserts the authentication level set by response.setAuthLevel(). |
context.assertError() | Asserts that response.setError() was called (i.e., an error code is set). |
context.assertErrorContains(String text) | Asserts the error detail message contains the given text. |
context.getErrorDetail() | Returns the error detail string set by response.setError() for use in manual assertions. |
context.assertVariable(PropagationScope scope, String name, String value) | Asserts a named variable equals the expected value in the given scope (NOTES, SESSION, OUTARGS, INARGS). |
context.assertVariableExists(PropagationScope scope, String name) | Asserts a named variable is present in the given scope. |
context.getContext().getSession(false) | Returns the session object directly for custom assertions (e.g. session.getData().get("key")). |
The AuthStateHarness tests one AuthState in isolation. There is no authentication engine wiring between states, so transitions (ResultCond) are not followed. Test the script logic and its effect on bindings — not the full authentication flow.