You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/19 20:48:26 UTC
(juneau) 01/02: Views write-path arc: row actions, selections, bulk mutations, modal/form defs, async jobs, and idempotency keys
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 211a919add460e2801046b654e2dfb8d0c06b62c
Author: James Bognar <ja...@apache.org>
AuthorDate: Wed Aug 19 16:47:51 2026 -0400
Views write-path arc: row actions, selections, bulk mutations, modal/form defs, async jobs, and idempotency keys
Adds the interactive write-path to juneau-rest-server-views: RowAction,
SelectionDef, BulkMutateDef, and WritePermit for row-level and bulk
operations; ActionResult, ModalDef, and FormDef for action responses;
AsyncJob/AsyncJobRef/AsyncJobRegistry/AsyncJobsMixin and IdempotencyKey
for asynchronous job handling and dedup; and a RawContentSinkScanner
security-test harness. Wires the behavior into juneau-views.js/.css and
juneau-pages.js with accompanying Java, JS, and browser tests. Also
extends the EscapeForScriptSink security scan (juneau-commons) to cover
the second raw-content sink.
---
.../EscapeForScriptSink_SecurityScan_Test.java | 12 +-
.../juneau/rest/server/views/ActionResult.java | 270 +++++
.../apache/juneau/rest/server/views/AsyncJob.java | 345 ++++++
.../juneau/rest/server/views/AsyncJobRef.java | 97 ++
.../juneau/rest/server/views/AsyncJobRegistry.java | 246 +++++
.../juneau/rest/server/views/AsyncJobsMixin.java | 188 ++++
.../juneau/rest/server/views/BulkMutateDef.java | 169 +++
.../apache/juneau/rest/server/views/FormDef.java | 69 ++
.../juneau/rest/server/views/IdempotencyKey.java | 186 ++++
.../apache/juneau/rest/server/views/ModalDef.java | 177 +++
.../apache/juneau/rest/server/views/PageDef.java | 21 +-
.../apache/juneau/rest/server/views/PageTable.java | 47 +-
.../apache/juneau/rest/server/views/RowAction.java | 320 ++++++
.../juneau/rest/server/views/SelectionDef.java | 130 +++
.../apache/juneau/rest/server/views/Subtab.java | 56 +-
.../org/apache/juneau/rest/server/views/Tab.java | 95 +-
.../apache/juneau/rest/server/views/ViewDef.java | 39 +-
.../apache/juneau/rest/server/views/ViewTable.java | 266 ++++-
.../juneau/rest/server/views/WritePermit.java | 104 ++
.../org/apache/juneau/views/juneau-pages.js | 2 +-
.../org/apache/juneau/views/juneau-views.css | 125 +++
.../org/apache/juneau/views/juneau-views.js | 1131 +++++++++++++++++++-
.../server/views/ActionResult_Contract_Test.java | 156 +++
.../rest/server/views/AsyncJobRegistry_Test.java | 193 ++++
.../rest/server/views/AsyncJob_BrowserTest.java | 223 ++++
.../juneau/rest/server/views/AsyncJob_Test.java | 205 ++++
.../server/views/AsyncJobsMixin_Serving_Test.java | 175 +++
.../rest/server/views/IdempotencyKey_Test.java | 116 ++
.../rest/server/views/ModalDef_FormDef_Test.java | 116 ++
.../rest/server/views/ModalResult_BrowserTest.java | 231 ++++
.../rest/server/views/PageDef_Contract_Test.java | 2 +-
.../rest/server/views/PageTableContent_Test.java | 284 +++++
.../rest/server/views/RawContentSinkScanner.java | 304 ++++++
.../views/RawContentSink_SecurityScan_Test.java | 243 +++++
.../server/views/RowActionCsrf_BrowserTest.java | 188 ++++
.../server/views/RowSelectionBulk_BrowserTest.java | 263 +++++
.../views/TabSubtabContent_Validation_Test.java | 214 ++++
.../server/views/TablePolling_Wiring_Test.java | 15 +-
.../rest/server/views/ViewMeta_Contract_Test.java | 86 +-
.../server/views/ViewTable_SelectionBulk_Test.java | 174 +++
.../rest/server/views/ViewsJs_AsyncJobs_Test.java | 200 ++++
.../server/views/ViewsJs_ModalResult_Test.java | 234 ++++
.../rest/server/views/ViewsJs_RowActions_Test.java | 123 +++
.../rest/server/views/ViewsJs_Selection_Test.java | 206 ++++
.../rest/server/views/ViewsMixin_Serving_Test.java | 2 +-
...ritePermit_SelectionDef_BulkMutateDef_Test.java | 175 +++
.../src/test/js/async-job.cjs | 248 +++++
.../src/test/js/modal-result.cjs | 227 ++++
.../src/test/js/row-actions.cjs | 153 +++
.../src/test/js/row-selection-bulk.cjs | 272 +++++
50 files changed, 9066 insertions(+), 57 deletions(-)
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
index 359492236d..a64508366c 100644
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
@@ -166,18 +166,24 @@ class EscapeForScriptSink_SecurityScan_Test extends TestBase {
/**
* Mutation check: the real {@code ViewTable} passes as-is, but removing the escaper turns it into a violation.
* This proves the guard is genuinely exercising that sink rather than passing it by accident.
+ *
+ * <p>
+ * {@code TODO-428} gave {@code ViewTable} a SECOND {@code <script>}-JSON sink — the independently
+ * versioned {@code BulkMutateDef} sidecar, alongside the pre-existing VIEW_META one — so this now
+ * expects TWO escaped sinks, and the blanket {@code escapeForScript}→{@code noEscape} mutation (which
+ * hits every occurrence in the file) now flags both.
*/
@Test void a12_realViewTable_passesButFailsWhenEscaperRemoved() throws Exception {
var root = requireRepoRoot();
var source = Files.readString(root.resolve(VIEW_TABLE));
var clean = ScriptJsonSinkScanner.scan(VIEW_TABLE, source);
- assertEquals(1, clean.sinks().size(), () -> "sinks: " + clean.sinks());
+ assertEquals(2, clean.sinks().size(), () -> "sinks: " + clean.sinks());
assertEquals(java.util.List.of(), clean.violations());
var mutated = ScriptJsonSinkScanner.scan(VIEW_TABLE, source.replace("escapeForScript", "noEscape"));
- assertEquals(1, mutated.sinks().size(), () -> "sinks: " + mutated.sinks());
- assertEquals(1, mutated.violations().size(),
+ assertEquals(2, mutated.sinks().size(), () -> "sinks: " + mutated.sinks());
+ assertEquals(2, mutated.violations().size(),
() -> "removing escapeForScript from ViewTable should be flagged: " + mutated.violations());
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
new file mode 100644
index 0000000000..08f8d20691
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
@@ -0,0 +1,270 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The typed result a row action's submit returns — the load-bearing half of the write-path arc (design doc
+ * §6.1/§6.2; the write-path half of {@code TODO-416}).
+ *
+ * <p>
+ * A raw HTML fragment response cannot carry the distinction the consumer's non-optimistic UI rule requires: a row
+ * must not show "acknowledged" because a request was <i>sent</i>, only because the remote system was read back and
+ * said so. This typed result carries exactly that distinction — an {@link Outcome outcome discriminator}, the
+ * authoritative {@link #row} data to re-render from, a {@link #replay} marker, and a {@link #refusalCode} — so
+ * the {@code juneau-views.js} runtime can paint what actually happened (success, failure, a <i>named</i> refusal, or
+ * an honest unknown) rather than what the request asked for.
+ *
+ * <h5 class='section'>Its own, independent contract version</h5>
+ * <p>
+ * The action-result is a <b>third</b> independently-versioned wire contract, alongside {@code VIEW_META}
+ * ({@link ViewDef#CONTRACT_VERSION}) and {@code PAGE_META} ({@link PageDef#CONTRACT_VERSION}). Its
+ * {@link #CONTRACT_VERSION} is deliberately its <b>own</b> constant and is <b>not</b> aliased to
+ * {@link ViewDef#CONTRACT_VERSION}: aliasing a per-view contract's version onto a separate contract couples two
+ * unrelated client/server locksteps (the exact mechanism behind the {@code PageDef}→{@code ViewDef} alias risk),
+ * so this contract versions itself.
+ *
+ * <h5 class='section'>Refusals are an opaque, namespaced code — not a framework-closed enum</h5>
+ * <p>
+ * A refusal's {@link #refusalCode} is an <b>opaque namespaced string</b>, not a framework-closed enum and not a
+ * free-text message. Refusals span two independent sources, and the framework must be able to render a refusal it
+ * has never heard of:
+ * <ul class='spaced-list'>
+ * <li><b>Framework transport codes</b> come from
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary.Reason#name() LoopbackBoundary.Reason.name()}
+ * (e.g. {@code CSRF_TOKEN_MISSING}), already on the wire as the {@code X-Loopback-Boundary} header and the
+ * rejection envelope's {@code reason}.
+ * <li><b>Consumer codes</b> are namespaced {@code app:} / {@code write-guard:} strings for concepts Juneau knows
+ * nothing about ("not armed", "arming expired", "confirmation does not match this incident", "a previous
+ * attempt's outcome is unknown").
+ * </ul>
+ * <p>
+ * The runtime renders ANY code as a visible <i>named</i> refusal without the framework needing to know consumer
+ * concepts exist.
+ *
+ * <h5 class='section'>Reserved async terminal outcomes</h5>
+ * <p>
+ * {@link Outcome#CANCELLED cancelled} and {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} are
+ * <b>reserved from day one</b> (as {@code VIEW_META} reserved {@code rowActions} before it was implemented), even
+ * though a synchronous write never emits them. The async/streaming variant ({@code TODO-425}) can therefore emit
+ * them without a second result-contract bump and a three-sided lockstep.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// A success carrying the authoritative row read back from the remote system.</jc>
+ * ActionResult <jv>ok</jv> = ActionResult.<jsm>success</jsm>(<jv>incidentReadBack</jv>);
+ *
+ * <jc>// A named refusal from the consumer's write gate.</jc>
+ * ActionResult <jv>no</jv> = ActionResult.<jsm>refusal</jsm>(<js>"write-guard:not-armed"</js>);
+ *
+ * <jc>// A recorded prior outcome, labelled as a replay rather than re-calling.</jc>
+ * ActionResult <jv>again</jv> = ActionResult.<jsm>success</jsm>(<jv>incidentReadBack</jv>).replay(<jk>true</jk>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link RowAction}
+ * <li class='jc'>{@link ModalDef}
+ * <li class='jc'>{@link IdempotencyKey}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class ActionResult {
+
+ /**
+ * The frozen action-result contract version — its <b>own</b> discriminator, deliberately NOT aliased to
+ * {@link ViewDef#CONTRACT_VERSION}. Bumped only on a breaking wire change to this contract.
+ */
+ public static final String CONTRACT_VERSION = "1";
+
+ /**
+ * The outcome discriminator a row-action submit reports.
+ *
+ * <p>
+ * Each constant carries the lowercase wire token emitted for the {@code outcome} field. The four synchronous
+ * outcomes ({@link #SUCCESS}, {@link #FAILURE}, {@link #REFUSAL}, {@link #UNKNOWN}) are what a synchronous write
+ * can emit; {@link #CANCELLED} and {@link #CANCELLED_AFTER_EFFECT} are reserved for the async variant
+ * ({@code TODO-425}) and are frozen here so that contract does not force a second result-contract bump.
+ */
+ public enum Outcome {
+
+ /** The write completed and {@link ActionResult#row} carries the authoritative row read back. */
+ SUCCESS("success"),
+
+ /** The write was attempted and failed. */
+ FAILURE("failure"),
+
+ /** The write was refused; {@link ActionResult#refusalCode} carries the named reason. */
+ REFUSAL("refusal"),
+
+ /** The write's outcome is genuinely unknown (e.g. a crash mid-write) — an honest, non-optimistic state. */
+ UNKNOWN("unknown"),
+
+ /** Reserved (async, {@code TODO-425}): the job was cancelled before any effect. */
+ CANCELLED("cancelled"),
+
+ /** Reserved (async, {@code TODO-425}): the job was cancelled after a partial/uncertain effect. */
+ CANCELLED_AFTER_EFFECT("cancelled-after-effect");
+
+ private final String wire;
+
+ Outcome(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this outcome.
+ *
+ * @return The wire token (e.g. {@code "success"}).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
+ /** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+ public String contractVersion = CONTRACT_VERSION;
+
+ /** The outcome discriminator wire token (see {@link Outcome#wire()}). */
+ public String outcome;
+
+ /**
+ * Whether this result is a recorded prior outcome replayed rather than re-executed; omitted from the wire when
+ * unset (the common, non-replay case).
+ */
+ public Boolean replay;
+
+ /**
+ * The opaque, namespaced refusal code for a {@link Outcome#REFUSAL refusal}; omitted from the wire for any other
+ * outcome. Framework codes come from
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary.Reason#name()}; consumer codes are namespaced
+ * {@code app:} / {@code write-guard:} strings (see the class javadoc).
+ */
+ public String refusalCode;
+
+ /** Optional human-readable supplementary message for any outcome; omitted from the wire when unset. */
+ public String message;
+
+ /**
+ * The authoritative row payload the runtime re-renders the row from (the {@code MERGE_ROW} case); omitted from
+ * the wire when unset. This is the row as the remote system actually reports it, not what the request asked for.
+ */
+ public Object row;
+
+ /**
+ * Starts a {@link Outcome#SUCCESS success} result carrying the authoritative row read back from the remote system.
+ *
+ * @param row The authoritative row payload to re-render from. Can be <jk>null</jk> (e.g. a navigate/redraw
+ * action that carries no row).
+ * @return A new {@link ActionResult}.
+ */
+ public static ActionResult success(Object row) {
+ var r = new ActionResult();
+ r.outcome = Outcome.SUCCESS.wire();
+ r.row = row;
+ return r;
+ }
+
+ /**
+ * Starts a {@link Outcome#FAILURE failure} result.
+ *
+ * @return A new {@link ActionResult}.
+ */
+ public static ActionResult failure() {
+ var r = new ActionResult();
+ r.outcome = Outcome.FAILURE.wire();
+ return r;
+ }
+
+ /**
+ * Starts a {@link Outcome#REFUSAL refusal} result carrying the named, opaque refusal code.
+ *
+ * @param refusalCode The opaque, namespaced refusal code. Must not be <jk>null</jk> or blank (a refusal with no
+ * code is exactly the free-text-message anti-pattern this contract forbids).
+ * @return A new {@link ActionResult}.
+ * @throws IllegalArgumentException If {@code refusalCode} is <jk>null</jk> or blank.
+ */
+ public static ActionResult refusal(String refusalCode) {
+ if (refusalCode == null || refusalCode.isBlank())
+ throw iaex("ActionResult refusalCode must not be null or blank.");
+ var r = new ActionResult();
+ r.outcome = Outcome.REFUSAL.wire();
+ r.refusalCode = refusalCode;
+ return r;
+ }
+
+ /**
+ * Starts an {@link Outcome#UNKNOWN unknown} result — an honest, non-optimistic terminal state.
+ *
+ * @return A new {@link ActionResult}.
+ */
+ public static ActionResult unknown() {
+ var r = new ActionResult();
+ r.outcome = Outcome.UNKNOWN.wire();
+ return r;
+ }
+
+ /**
+ * Sets the authoritative row payload to re-render from.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ActionResult row(Object value) {
+ row = value;
+ return this;
+ }
+
+ /**
+ * Marks this result as a recorded prior outcome replayed rather than re-executed.
+ *
+ * @param value <jk>true</jk> to mark this a replay; <jk>false</jk>/<jk>null</jk> to unset (omitting it from the wire).
+ * @return This object.
+ */
+ public ActionResult replay(Boolean value) {
+ replay = (value != null && value) ? Boolean.TRUE : null;
+ return this;
+ }
+
+ /**
+ * Sets the optional human-readable supplementary message.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ActionResult message(String value) {
+ message = value;
+ return this;
+ }
+
+ /**
+ * Sets the opaque, namespaced refusal code (see {@link #refusalCode}).
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ActionResult refusalCode(String value) {
+ refusalCode = value;
+ return this;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java
new file mode 100644
index 0000000000..81f61549b1
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJob.java
@@ -0,0 +1,345 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.server.views.ActionResult.*;
+
+/**
+ * A single in-memory, one-shot async job — the streamed-progress + terminal-result unit of the async write
+ * variant (design doc §6.3; {@code TODO-425}).
+ *
+ * <h5 class='section'>The stream URL IS the capability</h5>
+ * <p>
+ * A job is addressed by its {@link #id() id}, an unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * {@link java.security.SecureRandom} token minted by the {@link AsyncJobRegistry}, matching
+ * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s bar. Because a browser {@code EventSource}
+ * constructor cannot set an {@code X-Csrf-Token} header and cookies are unsound on a loopback port, the SSE GET that
+ * streams a job's progress is Host-checked at the boundary and <b>capability-gated by the unguessability of the
+ * id</b> — there is no session cookie and no CSRF-on-GET (HIGH-4). Consequently the id is a secret:
+ * {@link #toString()} never renders it.
+ *
+ * <h5 class='section'>Server-side hard limits (MED-6, a Task-11 disclosure bound)</h5>
+ * <p>
+ * A job caps its own streamed output at {@link #maxOutputBytes} bytes and terminates itself when that is exceeded,
+ * and it terminates itself once past its {@link #deadline() hard-timeout deadline}. Both are enforced <b>here</b>,
+ * server-side — a client cannot be trusted to cancel — and both are disclosure bounds, not only heap
+ * bounds: they cap how long, and how much, customer-adjacent triage content is streamed. The number of concurrent
+ * subscribers is capped at {@link #maxSubscribers} (a browser reload plus one straggler), enforced through
+ * {@link #acquireSubscriber()} / {@link #releaseSubscriber()}.
+ *
+ * <h5 class='section'>Terminal outcomes reuse the frozen {@link ActionResult} contract</h5>
+ * <p>
+ * A job settles exactly once, to an {@link ActionResult}: a producer-supplied {@link #complete(ActionResult)
+ * success/failure/refusal/unknown}, a {@link #cancel() cancellation} (distinguishing {@link Outcome#CANCELLED
+ * cancelled} from {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} via
+ * {@link #markEffectStarted()}), an output-limit termination, or a timeout. The reserved async outcomes are read
+ * off the frozen {@link Outcome} enum via its public {@code outcome} field — this contract is reused, never
+ * re-versioned.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>This class is thread-safe: a producer thread appends progress and settles the job while
+ * subscriber threads drain it through {@link #awaitUpdate(int, Duration)}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link AsyncJobRegistry}
+ * <li class='jc'>{@link AsyncJobsMixin}
+ * <li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class AsyncJob {
+
+ private final String id;
+ private final Instant createdAt;
+ private final Duration timeout;
+ private final long maxOutputBytes;
+ private final int maxSubscribers;
+
+ private final Object lock = new Object();
+ private final List<String> events = new ArrayList<>();
+ private long outputBytes;
+ private boolean effectStarted;
+ private int subscribers;
+ private ActionResult result; // Non-null iff terminal; the single settled outcome.
+
+ /**
+ * Constructor.
+ *
+ * @param id The unguessable capability id. Must not be <jk>null</jk> or blank.
+ * @param createdAt The instant the job was created (for the hard-timeout deadline). Must not be <jk>null</jk>.
+ * @param timeout The hard timeout after which the job self-terminates. Must not be <jk>null</jk>.
+ * @param maxOutputBytes The maximum number of streamed output bytes before the job self-terminates.
+ * @param maxSubscribers The maximum number of concurrent subscribers.
+ */
+ AsyncJob(String id, Instant createdAt, Duration timeout, long maxOutputBytes, int maxSubscribers) {
+ this.id = assertArgNotNull("id", id);
+ this.createdAt = assertArgNotNull("createdAt", createdAt);
+ this.timeout = assertArgNotNull("timeout", timeout);
+ this.maxOutputBytes = maxOutputBytes;
+ this.maxSubscribers = maxSubscribers;
+ }
+
+ /**
+ * The unguessable capability id — the whole of the job's access control (see the class javadoc).
+ *
+ * @return The id. Never <jk>null</jk> or blank.
+ */
+ public String id() { return id; }
+
+ /** @return The instant this job was created. */
+ public Instant createdAt() { return createdAt; }
+
+ /** @return The instant past which this job self-terminates on the next timeout check. */
+ public Instant deadline() { return createdAt.plus(timeout); }
+
+ /** @return The total number of streamed output bytes so far. */
+ public long outputBytes() { synchronized (lock) { return outputBytes; } }
+
+ /** @return The maximum streamed output bytes before self-termination. */
+ public long maxOutputBytes() { return maxOutputBytes; }
+
+ /** @return The maximum number of concurrent subscribers. */
+ public int maxSubscribers() { return maxSubscribers; }
+
+ /**
+ * Appends a progress event.
+ *
+ * <p>
+ * A no-op once the job is terminal. Enforces the server-side output cap: if this event would push the total
+ * over {@link #maxOutputBytes}, the job is instead terminated with a {@link Outcome#FAILURE failure} carrying an
+ * {@code app:output-limit-exceeded} message and the event is dropped — the cap is a data-egress bound, so
+ * the excess content is never streamed.
+ *
+ * @param text The progress text. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if the event was appended; <jk>false</jk> if the job was already terminal or the output
+ * cap was hit (which itself terminates the job).
+ */
+ public boolean progress(String text) {
+ assertArgNotNull("text", text);
+ synchronized (lock) {
+ if (result != null)
+ return false;
+ var add = (long) text.getBytes(UTF_8).length;
+ if (outputBytes + add > maxOutputBytes) {
+ settle(ActionResult.failure().message("app:output-limit-exceeded"));
+ return false;
+ }
+ outputBytes += add;
+ events.add(text);
+ lock.notifyAll();
+ return true;
+ }
+ }
+
+ /**
+ * Marks that this job's outbound effect has begun, so a subsequent {@link #cancel()} settles to
+ * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} rather than {@link Outcome#CANCELLED cancelled}
+ * (Q4: the two are different outcomes and must not be collapsed). A no-op once terminal.
+ */
+ public void markEffectStarted() {
+ synchronized (lock) {
+ if (result == null)
+ effectStarted = true;
+ }
+ }
+
+ /** @return Whether the outbound effect has begun (see {@link #markEffectStarted()}). */
+ public boolean effectStarted() { synchronized (lock) { return effectStarted; } }
+
+ /**
+ * Settles the job to a producer-supplied terminal result (success/failure/refusal/unknown).
+ *
+ * @param value The terminal result. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal (first settle
+ * wins).
+ */
+ public boolean complete(ActionResult value) {
+ assertArgNotNull("value", value);
+ synchronized (lock) {
+ if (result != null)
+ return false;
+ settle(value);
+ return true;
+ }
+ }
+
+ /**
+ * Cancels the job, settling it to {@link Outcome#CANCELLED cancelled} — or
+ * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} when {@link #markEffectStarted()} was called
+ * first. Enforced server-side; a client cannot be trusted to stop the work itself.
+ *
+ * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal.
+ */
+ public boolean cancel() {
+ synchronized (lock) {
+ if (result != null)
+ return false;
+ settle(cancelledResult(effectStarted));
+ return true;
+ }
+ }
+
+ /**
+ * Terminates the job if it is past its {@link #deadline() hard-timeout deadline}, settling it to a non-optimistic
+ * {@link Outcome#UNKNOWN unknown} — the effect may or may not have completed in the time cut off. This is
+ * the server-side timeout enforcement (HIGH-4/MED-6): it bounds both heap and, as a Task-11 disclosure bound, how
+ * long triage keeps streaming customer-adjacent content.
+ *
+ * @param now The current instant. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if this call timed the job out; <jk>false</jk> if it was already terminal or not yet past
+ * its deadline.
+ */
+ public boolean enforceTimeout(Instant now) {
+ assertArgNotNull("now", now);
+ synchronized (lock) {
+ if (result != null || ! now.isAfter(deadline()))
+ return false;
+ settle(ActionResult.unknown().message("app:hard-timeout-exceeded"));
+ return true;
+ }
+ }
+
+ /** @return Whether the job has settled to a terminal result. */
+ public boolean isTerminal() { synchronized (lock) { return result != null; } }
+
+ /** @return The terminal result, or <jk>null</jk> if the job is still running. */
+ public ActionResult result() { synchronized (lock) { return result; } }
+
+ /** @return The number of progress events buffered so far. */
+ public int eventCount() { synchronized (lock) { return events.size(); } }
+
+ /**
+ * Snapshots the progress events at or after {@code fromIndex} (for a re-attaching subscriber that replays
+ * buffered progress on page reload, per Q3).
+ *
+ * @param fromIndex The zero-based index to start from.
+ * @return A copy of the events from {@code fromIndex} onward (empty if none/out of range).
+ */
+ public List<String> eventsFrom(int fromIndex) {
+ synchronized (lock) {
+ if (fromIndex < 0 || fromIndex >= events.size())
+ return List.of();
+ return List.copyOf(events.subList(fromIndex, events.size()));
+ }
+ }
+
+ /**
+ * Blocks until there is a progress event at or after {@code fromIndex}, or the job is terminal, or {@code timeout}
+ * elapses — then returns whichever is available.
+ *
+ * <p>
+ * A terminal job returns immediately with any remaining buffered events plus its terminal result, so a single
+ * subscriber loop drains a completed (or re-attached) job in one pass.
+ *
+ * @param fromIndex The zero-based index the caller has already consumed up to.
+ * @param timeout The maximum time to wait for a new event. Must not be <jk>null</jk>.
+ * @return The new events (possibly empty) and the terminal result (<jk>null</jk> while still running).
+ */
+ public Update awaitUpdate(int fromIndex, Duration timeout) {
+ assertArgNotNull("timeout", timeout);
+ var deadlineNanos = System.nanoTime() + Math.max(0L, timeout.toNanos());
+ synchronized (lock) {
+ while (true) {
+ if (fromIndex < events.size() || result != null) {
+ var newEvents = fromIndex < events.size()
+ ? List.copyOf(events.subList(Math.max(0, fromIndex), events.size()))
+ : List.<String>of();
+ return new Update(newEvents, result);
+ }
+ var remaining = deadlineNanos - System.nanoTime();
+ if (remaining <= 0L)
+ return new Update(List.of(), null);
+ try {
+ lock.wait(remaining / 1_000_000L, (int) (remaining % 1_000_000L));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return new Update(List.of(), result);
+ }
+ }
+ }
+ }
+
+ /**
+ * Attempts to register a subscriber, enforcing the {@link #maxSubscribers} cap.
+ *
+ * @return <jk>true</jk> if a subscriber slot was acquired; <jk>false</jk> if the cap is already reached.
+ */
+ public boolean acquireSubscriber() {
+ synchronized (lock) {
+ if (subscribers >= maxSubscribers)
+ return false;
+ subscribers++;
+ return true;
+ }
+ }
+
+ /** Releases a subscriber slot previously acquired via {@link #acquireSubscriber()}. */
+ public void releaseSubscriber() {
+ synchronized (lock) {
+ if (subscribers > 0)
+ subscribers--;
+ }
+ }
+
+ /** @return The current number of registered subscribers. */
+ public int subscriberCount() { synchronized (lock) { return subscribers; } }
+
+ /** Settles the terminal result and wakes every waiting subscriber. Caller must hold {@link #lock}. */
+ private void settle(ActionResult value) {
+ result = value;
+ lock.notifyAll();
+ }
+
+ /** Builds a terminal {@link ActionResult} for a cancellation, reusing the frozen contract's reserved outcomes. */
+ private static ActionResult cancelledResult(boolean afterEffect) {
+ var r = new ActionResult();
+ r.outcome = (afterEffect ? Outcome.CANCELLED_AFTER_EFFECT : Outcome.CANCELLED).wire();
+ return r;
+ }
+
+ /**
+ * Returns a description that does <b>not</b> include the capability id.
+ *
+ * <p>
+ * The id is the stream's whole access control (see the class javadoc); a bean-dumping logger or debug view that
+ * stringified it would write a live capability somewhere it can be read back and used to attach to the stream.
+ *
+ * @return A value-free description.
+ */
+ @Override /* Object */
+ public String toString() {
+ return "AsyncJob(id=<redacted>,terminal=" + isTerminal() + ")";
+ }
+
+ /**
+ * The result of {@link AsyncJob#awaitUpdate(int, Duration)}: the newly-available progress events and the terminal
+ * result (<jk>null</jk> while the job is still running).
+ *
+ * @param events The new progress events since the caller's cursor. Never <jk>null</jk>.
+ * @param result The terminal result, or <jk>null</jk> if the job is still running.
+ */
+ public record Update(List<String> events, ActionResult result) {}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java
new file mode 100644
index 0000000000..d0b7b21b8a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRef.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The "job accepted" envelope a row action's submit returns <b>instead of</b> a terminal {@link ActionResult} when
+ * the write is asynchronous (design doc §6.3; {@code TODO-425}).
+ *
+ * <h5 class='section'>Why this is not a new {@code RowAction} wire field</h5>
+ * <p>
+ * Whether an action is asynchronous is a property of the <b>response</b>, not of the declared action: the same
+ * {@link RowAction} POST returns either a terminal {@link ActionResult} (synchronous) or this pointer (asynchronous).
+ * So the {@code RowAction} wire schema is untouched — no new field, no fail-loud contract bump — and the
+ * {@code juneau-views.js} runtime routes to the streaming path purely by recognizing a {@link #streamUrl} in the 2xx
+ * body. It carries no data to render, only capability pointers, so it needs no version handshake of its own.
+ *
+ * <h5 class='section'>The URLs are capabilities</h5>
+ * <p>
+ * {@link #streamUrl} points at the SSE progress stream and {@link #cancelUrl} at the cancel endpoint; both embed the
+ * job's unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit id (see {@link AsyncJob}), which is the whole of the
+ * access control (HIGH-4). The browser {@code EventSource} that consumes {@link #streamUrl} cannot set a CSRF header,
+ * which is precisely why the id must be unguessable rather than CSRF-gated.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link AsyncJobsMixin}
+ * <li class='jc'>{@link AsyncJob}
+ * <li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="jobId,streamUrl,cancelUrl")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class AsyncJobRef {
+
+ /** The job's unguessable capability id. */
+ public String jobId;
+
+ /** The URL of the SSE progress stream (a browser {@code EventSource} target); embeds {@link #jobId}. */
+ public String streamUrl;
+
+ /** The URL of the cancel endpoint (a non-safe POST); embeds {@link #jobId}. */
+ public String cancelUrl;
+
+ /**
+ * Builds a reference to the given job using {@code servlet:}-relative URLs against the {@link AsyncJobsMixin}
+ * mount — the same {@code servlet:} convention {@code ViewsMixin} uses for its asset URLs, resolved by
+ * Juneau's serializer against the current request.
+ *
+ * @param job The started job. Must not be <jk>null</jk>.
+ * @return A new reference carrying the job's id and its {@code servlet:}-relative stream + cancel URLs.
+ */
+ public static AsyncJobRef of(AsyncJob job) {
+ assertArgNotNull("job", job);
+ var r = new AsyncJobRef();
+ r.jobId = job.id();
+ r.streamUrl = "servlet:" + AsyncJobsMixin.streamPath(job.id());
+ r.cancelUrl = "servlet:" + AsyncJobsMixin.cancelPath(job.id());
+ return r;
+ }
+
+ /**
+ * Builds a reference carrying explicit stream/cancel URLs (for a consumer that resolves URLs itself, e.g. a
+ * template-rendered surface downstream of the {@code servlet:} rewrite).
+ *
+ * @param jobId The job's capability id. Must not be <jk>null</jk> or blank.
+ * @param streamUrl The SSE stream URL. Must not be <jk>null</jk> or blank.
+ * @param cancelUrl The cancel URL. Must not be <jk>null</jk> or blank.
+ * @return A new reference.
+ */
+ public static AsyncJobRef of(String jobId, String streamUrl, String cancelUrl) {
+ var r = new AsyncJobRef();
+ r.jobId = jobId;
+ r.streamUrl = streamUrl;
+ r.cancelUrl = cancelUrl;
+ return r;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
new file mode 100644
index 0000000000..f88b192ca2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.security.*;
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * An in-memory, per-process registry of {@link AsyncJob async jobs} — the single-process job store the async
+ * write variant runs on (design doc §6.3; {@code TODO-425}, Q2).
+ *
+ * <h5 class='section'>Single-process, by decision</h5>
+ * <p>
+ * Job state lives only in this object's heap, which is enough for a single-user loopback console and a documented
+ * limitation for anything else (Q2): a process restart drops every in-flight job, exactly as a restart invalidates
+ * every {@link org.apache.juneau.rest.server.filter.SynchronizerToken}. There is deliberately no store abstraction.
+ *
+ * <h5 class='section'>The id is the capability; the caps are load-bearing</h5>
+ * <p>
+ * Each job is keyed by an unguessable {@value #CAPABILITY_BITS}-bit {@link SecureRandom} id
+ * ({@value #CAPABILITY_BYTES} bytes, hex-encoded), matching
+ * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s width — the id <b>is</b> the stream's
+ * access control (HIGH-4). {@link #get(String)} of an unknown id returns empty, so a miss is indistinguishable from
+ * any other and the id space cannot be enumerated.
+ * <p>
+ * Because jobs keep running after the page navigates away (Q3) and stream customer-adjacent content, the following
+ * caps are enforced server-side and are security controls, not tuning knobs (MED-6, a Task-11 disclosure bound):
+ * <ul class='spaced-list'>
+ * <li>{@link #MAX_CONCURRENT_JOBS} concurrent (non-terminal) jobs — {@link #tryCreate()} refuses beyond it;
+ * <li>{@link AsyncJobRegistry#HARD_TIMEOUT} hard per-job timeout — scheduled here and also swept by
+ * {@link #sweepTimeouts()};
+ * <li>{@link AsyncJob#maxOutputBytes()} of streamed output per job — enforced in {@link AsyncJob#progress};
+ * <li>{@link AsyncJob#maxSubscribers()} concurrent subscribers per job — enforced in
+ * {@link AsyncJob#acquireSubscriber()}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link AsyncJob}
+ * <li class='jc'>{@link AsyncJobsMixin}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class AsyncJobRegistry implements AutoCloseable {
+
+ /** Number of random bytes behind a capability id. {@value #CAPABILITY_BITS} bits, matching {@code SynchronizerToken}. */
+ public static final int CAPABILITY_BYTES = 32;
+
+ /** Width of a capability id, in bits — well beyond the ≥128-bit bar. */
+ public static final int CAPABILITY_BITS = CAPABILITY_BYTES * 8;
+
+ /** Maximum number of concurrent (non-terminal) jobs; {@link #tryCreate()} refuses beyond this (MED-6). */
+ public static final int MAX_CONCURRENT_JOBS = 8;
+
+ /** Maximum number of concurrent subscribers per job (a reload plus one straggler) (MED-6). */
+ public static final int MAX_SUBSCRIBERS_PER_JOB = 2;
+
+ /** Maximum streamed output per job, in bytes (1 MiB); a data-egress bound, not only a heap bound (MED-6). */
+ public static final long MAX_OUTPUT_BYTES = 1024L * 1024L;
+
+ /** Hard per-job timeout; caps how long triage keeps streaming customer-adjacent content (HIGH-4/MED-6/Task-11). */
+ public static final Duration HARD_TIMEOUT = Duration.ofSeconds(120);
+
+ /** How long a terminal job is retained (for a page-reload re-attach) before it is reaped from the heap. */
+ static final Duration RETENTION = Duration.ofSeconds(60);
+
+ private final Map<String,AsyncJob> jobs = new ConcurrentHashMap<>();
+ private final SecureRandom random = new SecureRandom();
+ private final Clock clock;
+ private final Duration timeout;
+ private final long maxOutputBytes;
+ private final int maxSubscribers;
+ private final ScheduledExecutorService scheduler;
+ private final boolean ownsScheduler;
+
+ /**
+ * Creates a registry with the production defaults: a system clock, the {@link #HARD_TIMEOUT} timeout,
+ * {@link #MAX_OUTPUT_BYTES} / {@link #MAX_SUBSCRIBERS_PER_JOB} caps, and a private daemon scheduler that enforces
+ * each job's hard timeout.
+ */
+ public AsyncJobRegistry() {
+ this(Clock.systemUTC(), HARD_TIMEOUT, MAX_OUTPUT_BYTES, MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
+ }
+
+ /**
+ * Test/advanced constructor allowing an injected clock, timeout, caps and scheduler.
+ *
+ * @param clock The clock supplying job-creation and timeout-check instants. Must not be <jk>null</jk>.
+ * @param timeout The hard per-job timeout. Must not be <jk>null</jk>.
+ * @param maxOutputBytes The per-job streamed-output cap, in bytes.
+ * @param maxSubscribers The per-job concurrent-subscriber cap.
+ * @param scheduler The scheduler that enforces each job's hard timeout, or <jk>null</jk> to rely solely on
+ * {@link #sweepTimeouts()} (used by deterministic tests).
+ */
+ AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler) {
+ this(clock, timeout, maxOutputBytes, maxSubscribers, scheduler, false);
+ }
+
+ private AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler, boolean ownsScheduler) {
+ this.clock = assertArgNotNull("clock", clock);
+ this.timeout = assertArgNotNull("timeout", timeout);
+ this.maxOutputBytes = maxOutputBytes;
+ this.maxSubscribers = maxSubscribers;
+ this.scheduler = scheduler;
+ this.ownsScheduler = ownsScheduler;
+ }
+
+ private static ScheduledExecutorService defaultScheduler() {
+ return Executors.newSingleThreadScheduledExecutor(r -> {
+ var t = new Thread(r, "juneau-async-jobs");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ /**
+ * Attempts to create and register a new job with a freshly-minted capability id.
+ *
+ * <p>
+ * Sweeps timeouts first (so a slot held only by an expired job is freed), then refuses when the running-job cap
+ * is already reached — an unbounded set of keep-running jobs would be unbounded egress of customer-adjacent
+ * content (MED-6), so this refusal is load-bearing.
+ *
+ * @return The new job, or empty if {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running.
+ */
+ public Optional<AsyncJob> tryCreate() {
+ sweepTimeouts();
+ if (runningCount() >= MAX_CONCURRENT_JOBS)
+ return Optional.empty();
+ var job = new AsyncJob(mintId(), clock.instant(), timeout, maxOutputBytes, maxSubscribers);
+ jobs.put(job.id(), job);
+ scheduleTimeout(job);
+ return Optional.of(job);
+ }
+
+ /**
+ * Creates and registers a new job, throwing when the concurrency cap is reached.
+ *
+ * @return The new job.
+ * @throws IllegalStateException If {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running.
+ */
+ public AsyncJob create() {
+ return tryCreate().orElseThrow(() -> new IllegalStateException(
+ "Refusing to create an async job: the " + MAX_CONCURRENT_JOBS + "-concurrent-job cap is reached."));
+ }
+
+ /**
+ * Looks up a job by its capability id.
+ *
+ * <p>
+ * An unknown id returns empty — a miss is indistinguishable from any other, so the unguessable id space
+ * cannot be enumerated (HIGH-4).
+ *
+ * @param id The capability id. Can be <jk>null</jk>/blank (returns empty).
+ * @return The job, or empty if no such id is registered.
+ */
+ public Optional<AsyncJob> get(String id) {
+ if (id == null || id.isBlank())
+ return Optional.empty();
+ return Optional.ofNullable(jobs.get(id));
+ }
+
+ /**
+ * Cancels the job with the given id (see {@link AsyncJob#cancel()}).
+ *
+ * @param id The capability id. Can be <jk>null</jk>/blank.
+ * @return The (now terminal) job, or empty if no such id is registered.
+ */
+ public Optional<AsyncJob> cancel(String id) {
+ var job = get(id);
+ job.ifPresent(AsyncJob::cancel);
+ return job;
+ }
+
+ /** @return The number of concurrent (non-terminal) jobs currently registered. */
+ public int runningCount() {
+ var n = 0;
+ for (var job : jobs.values())
+ if (! job.isTerminal())
+ n++;
+ return n;
+ }
+
+ /** @return The total number of jobs currently registered (running plus retained-terminal). */
+ public int size() { return jobs.size(); }
+
+ /**
+ * Enforces the hard timeout on every job at the current clock instant and reaps terminal jobs past their
+ * retention window. Called on every {@link #tryCreate()}, scheduled per-job by the private scheduler, and callable
+ * directly by deterministic tests (with a mutable clock) instead of waiting on wall time.
+ */
+ public void sweepTimeouts() {
+ var now = clock.instant();
+ var reapBefore = now.minus(RETENTION);
+ var it = jobs.values().iterator();
+ while (it.hasNext()) {
+ var job = it.next();
+ job.enforceTimeout(now);
+ if (job.isTerminal() && job.createdAt().isBefore(reapBefore))
+ it.remove();
+ }
+ }
+
+ private void scheduleTimeout(AsyncJob job) {
+ if (scheduler == null)
+ return;
+ var delay = Duration.between(clock.instant(), job.deadline()).toMillis();
+ try {
+ scheduler.schedule(() -> job.enforceTimeout(clock.instant()), Math.max(0L, delay), TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException e) {
+ // Scheduler shut down (registry closed); the sweep on the next tryCreate() still enforces the timeout.
+ }
+ }
+
+ private String mintId() {
+ var bytes = new byte[CAPABILITY_BYTES];
+ random.nextBytes(bytes);
+ return HexFormat.of().formatHex(bytes);
+ }
+
+ /** Shuts down the private scheduler (if this registry created one). Registered jobs are left as-is. */
+ @Override /* AutoCloseable */
+ public void close() {
+ if (ownsScheduler && scheduler != null)
+ scheduler.shutdownNow();
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java
new file mode 100644
index 0000000000..5ed6db03ac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobsMixin.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import java.io.*;
+import java.time.*;
+
+import org.apache.juneau.http.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.*;
+
+/**
+ * Mixin interface that exposes the async-job SSE progress stream and cancel endpoint on any Juneau REST resource
+ * (design doc §6.3; {@code TODO-425}).
+ *
+ * <p>
+ * An implementing resource supplies its {@link AsyncJobRegistry} via {@link #asyncJobRegistry()} — typically a
+ * single per-process instance — and gets two endpoints for free:
+ * <ul class='spaced-list'>
+ * <li><b>{@code GET /juneau-jobs/{jobId}/stream}</b> — a held-open {@code text/event-stream} of the job's
+ * progress, terminated by one {@code result} event carrying the terminal {@link ActionResult}.
+ * <li><b>{@code POST /juneau-jobs/{jobId}/cancel}</b> — cancels the job and returns its terminal
+ * {@link ActionResult} (a {@code cancelled} / {@code cancelled-after-effect} outcome).
+ * </ul>
+ *
+ * <h5 class='section'>Authorization: the stream URL is the capability (HIGH-4)</h5>
+ * <p>
+ * The stream is a held-open <b>GET</b>, so {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} applies only
+ * its <b>Host</b> check (DNS-rebinding stays covered on every request); the Origin/CSRF/JSON checks apply to non-safe
+ * methods only. A browser {@code EventSource} cannot set an {@code X-Csrf-Token} header and cookies are unsound on a
+ * loopback port, so CSRF-on-GET is not an available control. Access is therefore gated by the <b>unguessability of
+ * the {@code jobId}</b> path segment — an {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * {@link java.security.SecureRandom} capability — and nothing else. An unknown id answers {@code 404} (a miss
+ * is indistinguishable, so the id space cannot be enumerated); a job already at its subscriber cap answers
+ * {@code 429}.
+ *
+ * <h5 class='section'>Server-side hard limits</h5>
+ * <p>
+ * Every limit is enforced in {@link AsyncJob} / {@link AsyncJobRegistry}, not here and not on the client: the hard
+ * timeout, the per-job output cap, the concurrent-job cap and the per-job subscriber cap. The stream loop simply
+ * drains the job until it is terminal, which the timeout guarantees will happen.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <ja>@Rest</ja>
+ * <jk>public class</jk> MyResource <jk>extends</jk> BasicRestServlet <jk>implements</jk> AsyncJobsMixin {
+ * <jk>private final</jk> AsyncJobRegistry <jf>jobs</jf> = <jk>new</jk> AsyncJobRegistry();
+ * <ja>@Override</ja> <jk>public</jk> AsyncJobRegistry asyncJobRegistry() { <jk>return</jk> <jf>jobs</jf>; }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link AsyncJobRegistry}
+ * <li class='jc'>{@link AsyncJob}
+ * <li class='jc'>{@link AsyncJobRef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface AsyncJobsMixin {
+
+ /** The URL path prefix for the async-job endpoints (relative to the host mount). */
+ String JOBS_PREFIX = "/juneau-jobs";
+
+ /** The URL path template of the SSE progress stream. */
+ String STREAM_PATH = JOBS_PREFIX + "/{jobId}/stream";
+
+ /** The URL path template of the cancel endpoint. */
+ String CANCEL_PATH = JOBS_PREFIX + "/{jobId}/cancel";
+
+ /** How long the stream loop blocks per poll while a job is still running (a liveness tick, not a timeout). */
+ Duration STREAM_POLL_INTERVAL = Duration.ofMillis(250);
+
+ /**
+ * Returns the {@link AsyncJobRegistry} backing this resource's async jobs.
+ *
+ * @return The registry. Never <jk>null</jk>.
+ */
+ AsyncJobRegistry asyncJobRegistry();
+
+ /**
+ * [GET /juneau-jobs/{jobId}/stream] — stream a job's progress as Server-Sent Events.
+ *
+ * <p>
+ * Replays any buffered progress (so a page reload re-attaches, per Q3), streams live progress as {@code progress}
+ * events, then sends exactly one {@code result} event carrying the terminal {@link ActionResult} as JSON and
+ * closes. Access is gated solely by the unguessable {@code jobId} (see the interface javadoc): an unknown id is a
+ * {@code 404}; a job already at its subscriber cap is a {@code 429}.
+ *
+ * @param jobId The job's unguessable capability id.
+ * @param res The REST response.
+ * @throws IOException If the stream could not be written.
+ */
+ @SuppressWarnings({
+ "resource" // False positive: asyncJobRegistry() returns the shared AutoCloseable registry (not owned here) and the fluent sse.sendEvent/flush calls return the same 'sse' already managed by the try-with-resources.
+ })
+ @RestGet(path=STREAM_PATH, summary="Async job SSE progress stream", swagger=@OpSwagger(ignore=true))
+ default void streamJob(@Path("jobId") String jobId, RestResponse res) throws IOException {
+ var job = asyncJobRegistry().get(jobId).orElse(null);
+ if (job == null) {
+ res.setStatus(404);
+ return;
+ }
+ if (! job.acquireSubscriber()) {
+ res.setStatus(429);
+ return;
+ }
+ try (var sse = res.sse()) {
+ var cursor = 0;
+ while (true) {
+ var update = job.awaitUpdate(cursor, STREAM_POLL_INTERVAL);
+ for (var event : update.events()) {
+ sse.sendEvent("progress", event);
+ cursor++;
+ }
+ sse.flush();
+ if (update.result() != null) {
+ sse.sendEvent("result", Json.of(update.result()));
+ sse.flush();
+ return;
+ }
+ }
+ } finally {
+ job.releaseSubscriber();
+ }
+ }
+
+ /**
+ * [POST /juneau-jobs/{jobId}/cancel] — cancel a job and return its terminal result.
+ *
+ * <p>
+ * A non-safe POST, so the boundary applies its full Origin/CSRF/JSON checks. Cancellation is enforced
+ * server-side (a client cannot be trusted to stop the work); the returned outcome distinguishes {@code cancelled}
+ * from {@code cancelled-after-effect} (Q4). An unknown id is a {@code 404} carrying a named refusal.
+ *
+ * @param jobId The job's unguessable capability id.
+ * @param res The REST response.
+ * @return The job's terminal {@link ActionResult}.
+ */
+ @SuppressWarnings({
+ "resource" // False positive: asyncJobRegistry() returns the shared AutoCloseable registry, which this method borrows but does not own.
+ })
+ @RestPost(path=CANCEL_PATH, summary="Cancel an async job", swagger=@OpSwagger(ignore=true))
+ default ActionResult cancelJob(@Path("jobId") String jobId, RestResponse res) {
+ var job = asyncJobRegistry().get(jobId).orElse(null);
+ if (job == null) {
+ res.setStatus(404);
+ return ActionResult.refusal("app:unknown-job");
+ }
+ job.cancel();
+ return job.result();
+ }
+
+ /**
+ * Builds the servlet-relative stream path for a job id.
+ *
+ * @param jobId The job's capability id.
+ * @return The stream path, e.g. {@code /juneau-jobs/<id>/stream}.
+ */
+ static String streamPath(String jobId) {
+ return JOBS_PREFIX + "/" + jobId + "/stream";
+ }
+
+ /**
+ * Builds the servlet-relative cancel path for a job id.
+ *
+ * @param jobId The job's capability id.
+ * @return The cancel path, e.g. {@code /juneau-jobs/<id>/cancel}.
+ */
+ static String cancelPath(String jobId) {
+ return JOBS_PREFIX + "/" + jobId + "/cancel";
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java
new file mode 100644
index 0000000000..a1521a55fe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BulkMutateDef.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The bulk-mutate opt-in for a {@link ViewTable} — the second, INDEPENDENT half of {@code TODO-428}'s
+ * separability guarantee (design doc §9.3/§6.2; HIGH-5).
+ *
+ * <h5 class='section'>Two required parameters make it structurally, not conventionally, safe</h5>
+ * <p>
+ * There is no zero-argument (or permit-less) way to obtain a {@link BulkMutateDef}: {@link #create(WritePermit,
+ * SelectionDef)} takes both a {@link WritePermit} <b>and</b> the {@link SelectionDef} it operates against as
+ * required positional parameters. Omitting either is a <b>compile</b> failure, not a missed javadoc sentence
+ * — the same discipline the console's own {@code WriteGuard} uses for its {@code WritePermit} (source
+ * design §5.4). This closes both halves of HIGH-5 by construction:
+ * <ul class='spaced-list'>
+ * <li>The {@link WritePermit} proves the caller's own write-gate was consulted before this affordance is even
+ * configured — a bulk-mutate control cannot be reached by a caller who forgot to check.
+ * <li>The {@link SelectionDef} proves a stable-row-id selection mechanism already exists for this table to
+ * target — a {@link BulkMutateDef} can never float free of the selection it acts on, so the reverse
+ * question ("which rows does this bulk action see?") is never ambiguous. This is the only direction of
+ * coupling between the two opt-ins: bulk-mutate requires selection, but selection never requires (or even
+ * references) bulk-mutate — so enabling selection alone can never surface a bulk-mutate control.
+ * </ul>
+ *
+ * <h5 class='section'>Reuses {@link RowAction} and {@link ActionResult} verbatim — N per-row writes, per-target results</h5>
+ * <p>
+ * A bulk action is declared as an ordinary {@link RowAction} (the {@link RowAction} wire schema is completely
+ * untouched by this type); {@code juneau-views.js} executes a bulk action as N independent, per-row invocations
+ * of the SAME single-action submit path ({@code submitRowAction}) it already uses for the per-row action menu
+ * — one fail-closed CSRF POST per selected row, each with its OWN {@code data-juneau-inflight} marker (the
+ * {@code TODO-417} per-row in-flight model, driven per target) and its OWN typed {@link ActionResult}, rendered
+ * independently into that row's own outcome banner. There is deliberately <b>no</b> aggregate "bulk result": a
+ * partial failure among N targets can never be hidden behind an aggregate success, and each target's
+ * {@code data-juneau-inflight} marker clears on ITS OWN terminal outcome (success/failure/refusal/unknown) so a
+ * stuck marker on one target can never halt the whole table's polling (MED-4).
+ *
+ * <h5 class='section'>Its own, independent contract version</h5>
+ * <p>
+ * Unlike {@link SelectionDef} (which is pure DOM attributes, never wire data), a bulk action LIST is itself wire
+ * data the client must parse — so it travels as its own JSON sidecar (distinct from the {@code VIEW_META}
+ * one), with its own {@link #CONTRACT_VERSION}, exactly mirroring how {@link ActionResult} versions itself
+ * independently of {@link ViewDef#CONTRACT_VERSION} rather than aliasing it. A version bump here can therefore
+ * NEVER force a {@link ViewDef#CONTRACT_VERSION} bump (or vice versa): the two contracts are unrelated, so this
+ * opt-in cannot back the framework into the very lockstep {@code TODO-428}'s R2 guard exists to avoid. The
+ * {@link WritePermit} carried by this builder is a server-side-only construction guard and is <b>never</b>
+ * serialized — only {@link #contractVersion} and {@link #actions} reach the wire.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * SelectionDef <jv>selection</jv> = SelectionDef.<jsm>create</jsm>(<js>"id"</js>);
+ * <jc>// Requires a permit the CALLER's own write-gate has already minted, and the selection it targets.</jc>
+ * BulkMutateDef <jv>bulk</jv> = BulkMutateDef.<jsm>create</jsm>(WritePermit.<jsm>forCapability</jsm>(<js>"incident:bulk-ack"</js>), <jv>selection</jv>)
+ * .actions(RowAction.<jsm>create</jsm>(<js>"ack"</js>).label(<js>"Acknowledge"</js>)
+ * .endpoint(<js>"servlet:/incidents/ack"</js>).method(RowAction.Method.<jsf>POST</jsf>));
+ * Div <jv>markup</jv> = ViewTable.<jsm>of</jsm>(<jv>req</jv>, <jv>viewDef</jv>, <jv>rows</jv>, <jv>bulk</jv>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link SelectionDef}
+ * <li class='jc'>{@link WritePermit}
+ * <li class='jc'>{@link RowAction}
+ * <li class='jc'>{@link ActionResult}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,actions")
+public final class BulkMutateDef {
+
+ /**
+ * The frozen bulk-actions contract version — its <b>own</b> discriminator, deliberately NOT aliased to
+ * {@link ViewDef#CONTRACT_VERSION} (see the class javadoc). Bumped only on a breaking wire change to this
+ * contract.
+ */
+ public static final String CONTRACT_VERSION = "1";
+
+ /** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+ public String contractVersion = CONTRACT_VERSION;
+
+ /** The declared bulk actions, in menu order. Each is an ordinary, unmodified {@link RowAction}. */
+ public List<RowAction> actions;
+
+ /**
+ * The construction-time proof that the caller's own write-gate was consulted. Deliberately private and NOT
+ * listed in {@code @BeanType(properties=...)}: it must never reach the wire.
+ */
+ private final WritePermit permit;
+
+ /** The selection this bulk config operates against. Also excluded from the wire (see {@link #permit}). */
+ private final SelectionDef selection;
+
+ private BulkMutateDef(WritePermit permit, SelectionDef selection) {
+ this.permit = permit;
+ this.selection = selection;
+ }
+
+ /**
+ * Starts a new {@link BulkMutateDef}, requiring proof that the caller's write-gate was consulted and the
+ * selection this bulk config will operate against.
+ *
+ * @param permit A permit the caller's OWN write-gate minted after its own checks passed. Must not be
+ * <jk>null</jk>.
+ * @param selection The {@link SelectionDef} this bulk config operates against. Must not be <jk>null</jk>.
+ * @return A new {@link BulkMutateDef} with no actions yet declared.
+ * @throws IllegalArgumentException If {@code permit} or {@code selection} is <jk>null</jk>.
+ */
+ public static BulkMutateDef create(WritePermit permit, SelectionDef selection) {
+ if (permit == null)
+ throw iaex("BulkMutateDef requires a non-null WritePermit (the caller's own write-gate must mint one first).");
+ if (selection == null)
+ throw iaex("BulkMutateDef requires a non-null SelectionDef (the selection this bulk config operates against).");
+ return new BulkMutateDef(permit, selection);
+ }
+
+ /**
+ * Declares the bulk actions.
+ *
+ * @param value The actions, in menu order. Each declares a mutating request executed once per selected row
+ * (see the class javadoc's N-per-row-writes section). Must not be <jk>null</jk> or empty.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or empty.
+ */
+ public BulkMutateDef actions(RowAction...value) {
+ if (value == null || value.length == 0)
+ throw iaex("BulkMutateDef requires at least one action.");
+ actions = l(value);
+ return this;
+ }
+
+ /**
+ * The permit proving the caller's write-gate was consulted before this config was built.
+ *
+ * @return The permit. Never <jk>null</jk>.
+ */
+ public WritePermit permit() {
+ return permit;
+ }
+
+ /**
+ * The selection this bulk config operates against.
+ *
+ * @return The selection. Never <jk>null</jk>.
+ */
+ public SelectionDef selection() {
+ return selection;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
new file mode 100644
index 0000000000..4bb53972bf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The declarative source of a row-action modal's input form (the form half of {@code TODO-416}; {@code TODO-399}
+ * Decision 8's "form inside the dialog").
+ *
+ * <h5 class='section'>FreeMarker-first</h5>
+ * <p>
+ * The form's fields are sourced <b>FreeMarker-template-first</b>, consistent with the rest of this module's
+ * server-render story: a {@link #template} names the server-side template that produces the form's field markup,
+ * rendered against the current record. The alternative bean→form generator ({@code FormDef.of(beanType)}) is a
+ * deliberately deferred follow-on ({@code TODO-399} Decision 8's "bean→form later"), so this MVP exposes
+ * only the template source; the {@code beanType} field is reserved and omitted from the wire until then.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * FormDef <jv>form</jv> = FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ModalDef}
+ * <li class='jc'>{@link RowAction}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="template")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class FormDef {
+
+ /** The FreeMarker template reference that renders the form's field markup against the current record. */
+ public String template;
+
+ /**
+ * Creates a form sourced from the specified FreeMarker template reference.
+ *
+ * @param template The template reference (a resolvable URL/name). Must not be <jk>null</jk> or blank.
+ * @return A new {@link FormDef}.
+ * @throws IllegalArgumentException If {@code template} is <jk>null</jk> or blank.
+ */
+ public static FormDef ofTemplate(String template) {
+ if (template == null || template.isBlank())
+ throw iaex("FormDef template must not be null or blank.");
+ var f = new FormDef();
+ f.template = template;
+ return f;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java
new file mode 100644
index 0000000000..c76e8685f3
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/IdempotencyKey.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.nio.charset.*;
+import java.security.*;
+import java.util.*;
+
+/**
+ * A server-minted idempotency key for a row-action submit, bound at mint time to the {@code (action, targetId)} pair
+ * it was issued for (design doc §6.2; the idempotency half of {@code TODO-416}).
+ *
+ * <h5 class='section'>Why the key is server-minted and bound, not a client UUID</h5>
+ * <p>
+ * The key is minted <b>when the modal opens</b> — the server is already in the loop then, since the modal-open
+ * confirmation is server-rendered against the current record — so a double-click, an impatient re-submit and a
+ * browser retry all carry the <b>same</b> key and collapse to one effect. The modal owns the key's <i>carry</i>
+ * lifecycle; it does <b>not</b> mint it.
+ * <p>
+ * A client-generated key is not the security property. A globally-unique key looked up <i>only by key</i> would let
+ * a recorded terminal outcome for incident A be replayed under a submit for incident B — returning
+ * A's "success" for a write B never performed, and resurrecting the "resolve an incident that was re-triggered in the
+ * interim" case. This key is therefore bound to {@code (action, targetId)} at mint time: a submit whose
+ * {@code (action, targetId)} does not {@link #matches(String, String) match} the binding is a <b>named refusal</b>,
+ * never a replayed success.
+ *
+ * <h5 class='section'>Unforgeability</h5>
+ * <p>
+ * The key value is {@value #KEY_BYTES} random bytes ({@value #KEY_BITS} bits) from {@link SecureRandom},
+ * hex-encoded — well beyond the ≥128-bit bar, matching {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s
+ * 256-bit reference. The binding is what makes it safe; the width is what makes it unguessable.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// At modal-open: mint a key bound to this action + this row.</jc>
+ * IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mint</jsm>(<js>"ack"</js>, <jv>incidentId</jv>);
+ * <jv>modal</jv>.idempotencyKey(<jv>key</jv>.value());
+ *
+ * <jc>// At submit: rehydrate the recorded binding and check it against the submitted (action, targetId).</jc>
+ * <jk>if</jk> (! <jv>recorded</jv>.matches(<jv>submittedAction</jv>, <jv>submittedTargetId</jv>))
+ * <jk>return</jk> ActionResult.<jsm>refusal</jsm>(<js>"write-guard:idempotency-binding-mismatch"</js>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ModalDef}
+ * <li class='jc'>{@link ActionResult}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.filter.SynchronizerToken}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class IdempotencyKey {
+
+ /** Width of a minted key value, in bits — well beyond the ≥128-bit bar. */
+ private static final int KEY_BITS = 256;
+
+ /** Number of random bytes behind a minted key value, derived from {@link #KEY_BITS}. */
+ private static final int KEY_BYTES = KEY_BITS / 8;
+
+ private final String value;
+ private final String action;
+ private final String targetId;
+
+ private IdempotencyKey(String value, String action, String targetId) {
+ this.value = value;
+ this.action = action;
+ this.targetId = targetId;
+ }
+
+ /**
+ * Mints a fresh key bound to the specified {@code (action, targetId)} pair.
+ *
+ * @param action The stable action id this key is issued for. Must not be <jk>null</jk> or blank.
+ * @param targetId The id of the row/target this key is issued for. Must not be <jk>null</jk> or blank.
+ * @return A new key holding a fresh {@value #KEY_BITS}-bit secret, hex-encoded, bound to {@code (action, targetId)}.
+ * @throws IllegalArgumentException If {@code action} or {@code targetId} is <jk>null</jk> or blank.
+ */
+ public static IdempotencyKey mint(String action, String targetId) {
+ requireNonBlank("action", action);
+ requireNonBlank("targetId", targetId);
+ var bytes = new byte[KEY_BYTES];
+ new SecureRandom().nextBytes(bytes);
+ return new IdempotencyKey(HexFormat.of().formatHex(bytes), action, targetId);
+ }
+
+ /**
+ * Rehydrates a previously-minted key from its recorded value and binding.
+ *
+ * <p>
+ * Intended for the submit path: the consumer looks up the recorded {@code (value, action, targetId)} it minted at
+ * modal-open and reconstructs this to {@link #matches(String, String) check} it against the submitted pair.
+ *
+ * @param value The recorded key value. Must not be <jk>null</jk> or blank.
+ * @param action The action id the key was bound to at mint time. Must not be <jk>null</jk> or blank.
+ * @param targetId The target id the key was bound to at mint time. Must not be <jk>null</jk> or blank.
+ * @return A key holding the recorded value and binding.
+ * @throws IllegalArgumentException If any argument is <jk>null</jk> or blank.
+ */
+ public static IdempotencyKey of(String value, String action, String targetId) {
+ requireNonBlank("value", value);
+ requireNonBlank("action", action);
+ requireNonBlank("targetId", targetId);
+ return new IdempotencyKey(value, action, targetId);
+ }
+
+ /**
+ * The opaque key value, for carrying in the modal and echoing back on the submit.
+ *
+ * @return The key value. Never <jk>null</jk> or blank.
+ */
+ public String value() { return value; }
+
+ /**
+ * The action id this key is bound to.
+ *
+ * @return The bound action id.
+ */
+ public String action() { return action; }
+
+ /**
+ * The target id this key is bound to.
+ *
+ * @return The bound target id.
+ */
+ public String targetId() { return targetId; }
+
+ /**
+ * Whether a submitted {@code (action, targetId)} matches this key's binding.
+ *
+ * <p>
+ * A mismatch must be answered with a <b>named refusal</b>, never a replayed success: it is a key being submitted
+ * against a different action or a different target than it was minted for. Compares the target id in constant
+ * time so a mismatch cannot be probed character-by-character.
+ *
+ * @param submittedAction The action id presented by the submit. Can be <jk>null</jk>.
+ * @param submittedTargetId The target id presented by the submit. Can be <jk>null</jk>.
+ * @return <jk>true</jk> only if both the action and the target id match this key's binding exactly.
+ */
+ public boolean matches(String submittedAction, String submittedTargetId) {
+ if (submittedAction == null || submittedTargetId == null)
+ return false;
+ return constantTimeEquals(action, submittedAction) && constantTimeEquals(targetId, submittedTargetId);
+ }
+
+ private static boolean constantTimeEquals(String a, String b) {
+ return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void requireNonBlank(String name, String value) {
+ assertArgNotNull(name, value);
+ if (value.isBlank())
+ throw iaex("Argument ''%s'' must not be blank.", name);
+ }
+
+ /**
+ * Returns a description that does <b>not</b> include the key value.
+ *
+ * <p>
+ * The value is a secret; a bean-dumping logger or a debug view that stringifies it would otherwise write it
+ * somewhere it can be read back and replayed. The binding is safe to show.
+ *
+ * @return A value-free description.
+ */
+ @Override /* Object */
+ public String toString() {
+ return "IdempotencyKey(value=<redacted>,action=" + action + ",targetId=" + targetId + ")";
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java
new file mode 100644
index 0000000000..9d3d9c3ccf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ModalDef.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The declarative modal (dialog) a {@code present=}{@link RowAction.Present#DIALOG dialog} row action opens —
+ * the payload the <b>modal-open confirmation fetch</b> returns (design doc §6.2; the modal half of
+ * {@code TODO-416}).
+ *
+ * <h5 class='section'>Safe-by-construction confirmation body</h5>
+ * <p>
+ * The confirmation body is <b>typed structured fields</b> ({@link #fields}: incident #, title, service, current
+ * status — the source design §6.2 field set), server-rendered against the current, authoritative record and
+ * bound to the target identifier. The {@code juneau-views.js} runtime paints each field's value with
+ * {@code textContent} — NEVER {@code innerHTML}, never raw markup, never the non-table panel-content sink.
+ * Live/remote data is attacker-influenceable (anyone who can title an incident or post in a watched thread) and this
+ * origin holds the CSRF token, so an HTML sink here would be stored-XSS → token-theft →
+ * arbitrary-write; typed fields painted with {@code textContent} close that off by construction.
+ *
+ * <h5 class='section'>The idempotency key rides here</h5>
+ * <p>
+ * {@link #idempotencyKey} is the server-minted {@link IdempotencyKey#value() key value}, minted at modal-open and
+ * bound to {@code (action, targetId)}. The runtime carries it verbatim on the submit so a double-click / re-submit /
+ * browser retry all collapse to one effect; the server checks the {@link IdempotencyKey#matches(String, String)
+ * binding} on submit and answers a mismatch with a named refusal, never a replayed success.
+ *
+ * <h5 class='section'>Not a new {@code RowAction} wire field</h5>
+ * <p>
+ * This is the response the {@link RowAction#form form-source URL} returns, <b>not</b> a new field on the frozen
+ * {@link RowAction} wire schema: the modal's declaration lives in this separately-served payload so the row-action
+ * contract stays frozen.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mint</jsm>(<js>"ack"</js>, <jv>incidentId</jv>);
+ * ModalDef <jv>modal</jv> = ModalDef.<jsm>create</jsm>(<js>"Acknowledge this incident?"</js>)
+ * .field(<js>"Incident"</js>, <jv>incidentNumber</jv>)
+ * .field(<js>"Title"</js>, <jv>incidentTitle</jv>)
+ * .field(<js>"Service"</js>, <jv>serviceName</jv>)
+ * .field(<js>"Current status"</js>, <jv>currentStatus</jv>)
+ * .form(FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>))
+ * .idempotencyKey(<jv>key</jv>.value());
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link FormDef}
+ * <li class='jc'>{@link IdempotencyKey}
+ * <li class='jc'>{@link ActionResult}
+ * <li class='jc'>{@link RowAction}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="title,fields,form,idempotencyKey")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class ModalDef {
+
+ /**
+ * A single typed confirmation field: a label and a value, painted client-side with {@code textContent} (never
+ * {@code innerHTML}).
+ *
+ * @since 10.0.0
+ */
+ @BeanType(properties="label,value")
+ public static class Field {
+
+ /** The field label shown to the user. */
+ public String label;
+
+ /** The field value, read back from the current authoritative record. */
+ public String value;
+
+ /**
+ * Creates a typed confirmation field.
+ *
+ * @param label The field label. Must not be <jk>null</jk> or blank.
+ * @param value The field value. Can be <jk>null</jk> (rendered as an empty value).
+ * @return A new {@link Field}.
+ * @throws IllegalArgumentException If {@code label} is <jk>null</jk> or blank.
+ */
+ public static Field of(String label, String value) {
+ if (label == null || label.isBlank())
+ throw iaex("ModalDef.Field label must not be null or blank.");
+ var f = new Field();
+ f.label = label;
+ f.value = value;
+ return f;
+ }
+ }
+
+ /** The modal title / confirmation prompt. */
+ public String title;
+
+ /** The typed confirmation fields, in display order; omitted from the wire when none are declared. */
+ public List<Field> fields;
+
+ /** The optional input form; omitted from the wire when unset (a confirm-only modal). */
+ public FormDef form;
+
+ /**
+ * The server-minted idempotency key value the runtime carries on the submit; omitted from the wire when unset.
+ * See {@link IdempotencyKey}.
+ */
+ public String idempotencyKey;
+
+ /**
+ * Starts a new {@link ModalDef} with the specified title / confirmation prompt.
+ *
+ * @param title The modal title / confirmation prompt. Must not be <jk>null</jk> or blank.
+ * @return A new mutable {@link ModalDef} to chain builder calls on.
+ * @throws IllegalArgumentException If {@code title} is <jk>null</jk> or blank.
+ */
+ public static ModalDef create(String title) {
+ if (title == null || title.isBlank())
+ throw iaex("ModalDef title must not be null or blank.");
+ var m = new ModalDef();
+ m.title = title;
+ return m;
+ }
+
+ /**
+ * Adds one typed confirmation field.
+ *
+ * @param label The field label. Must not be <jk>null</jk> or blank.
+ * @param value The field value (read back from the current record). Can be <jk>null</jk>.
+ * @return This object.
+ */
+ public ModalDef field(String label, String value) {
+ if (fields == null)
+ fields = l();
+ fields.add(Field.of(label, value));
+ return this;
+ }
+
+ /**
+ * Sets the optional input form.
+ *
+ * @param value The form. Can be <jk>null</jk> to unset (a confirm-only modal).
+ * @return This object.
+ */
+ public ModalDef form(FormDef value) {
+ form = value;
+ return this;
+ }
+
+ /**
+ * Sets the server-minted idempotency key value carried on the submit (see {@link IdempotencyKey}).
+ *
+ * @param value The key value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ModalDef idempotencyKey(String value) {
+ idempotencyKey = value;
+ return this;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
index a7f9a1d727..937544d04b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
@@ -119,11 +119,14 @@ public class PageDef {
* Finalizes the builder, validating the composed tab tree, and returns the wire-ready {@link PageDef}.
*
* <p>
- * Validation (design doc §"Bean model"): at least one tab must be declared; every {@link Tab} must declare
- * exactly one of {@link Tab#view}/{@link Tab#subtabs} (delegated to {@link Tab#validate()}, which also checks
- * sub-tab id uniqueness <i>within</i> that tab); every {@link Tab#id} must be unique across the page; and every
- * referenced {@link ViewDef#id} (from a leaf tab's view or any subtab's view) must be unique across the whole
- * page, so hash routing and sidecar lookup stay unambiguous.
+ * Validation (design doc §"Bean model"; TODO-420 widens the panel-body shape from a view/subtabs
+ * exclusive-or to the matrix documented on {@link Tab}/{@link Subtab}): at least one tab must be declared;
+ * every {@link Tab} must satisfy its own panel-body matrix and every declared {@link Subtab} its own
+ * (delegated to {@link Tab#validate()}, which also checks sub-tab id uniqueness <i>within</i> that tab); every
+ * {@link Tab#id} must be unique across the page; and every referenced {@link ViewDef#id} (from a leaf tab's
+ * view or any subtab's view — a tab or subtab carrying {@link Tab#content}/{@link Subtab#content}
+ * instead references no {@link ViewDef} and contributes nothing here) must be unique across the whole page, so
+ * hash routing and sidecar lookup stay unambiguous.
*
* @return This object.
* @throws IllegalArgumentException On any validation rule violation.
@@ -142,12 +145,12 @@ public class PageDef {
t.validate();
if (!tabIds.add(t.id))
throw iaex("PageDef '%s': duplicate tab id '%s'.", id, t.id);
- if (t.view != null) {
+ if (t.view != null)
addViewId(viewIds, t.view.id);
- } else {
+ if (t.subtabs != null)
for (var s : t.subtabs)
- addViewId(viewIds, s.view.id);
- }
+ if (s.view != null)
+ addViewId(viewIds, s.view.id);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
index 7e0dbb56dd..6d902cf500 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -79,7 +79,27 @@ import org.apache.juneau.marshall.marshaller.*;
* <p>
* A light-weight tab tree (id/label/subtabs only — no embedded {@code ViewDef} content, since the page
* runtime's lazy-init binding layer locates a panel's {@code table[data-juneau-view]} elements directly via the DOM
- * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>.
+ * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>. This
+ * projection is built by {@link #buildMeta(PageDef)}, which copies only the fields above from each {@link Tab}/
+ * {@link Subtab} — {@link Tab#content}/{@link Subtab#content} (TODO-420) are never read by it and so
+ * structurally cannot reach PAGE_META, regardless of what {@code @BeanType} pins on {@link Tab}/{@link Subtab};
+ * this is a stronger guarantee than reserve-and-omit (there is nothing to omit because there is no code path that
+ * copies it), and it is why the field is also simply absent from {@link Tab}/{@link Subtab}'s own
+ * {@code @BeanType} property lists, so the framework's wire contract (and {@link PageDef#CONTRACT_VERSION}) needs
+ * no bump to add it.
+ *
+ * <h5 class='section'>Panel-body content (TODO-420): template engine, trusted / first-party content only</h5>
+ * <p>
+ * {@link Tab#content}/{@link Subtab#content} let a panel hold raw markup instead of (or, for {@link Tab#content},
+ * above) a {@code ViewDef}-backed table — see the panel-body matrices on {@link Tab}/{@link Subtab}. This
+ * emitter writes that markup <b>verbatim</b>, via {@code rawText(...)}, exactly like the PAGE_META/VIEW_META JSON
+ * sidecars use {@code rawText(...)} for their own (separately escaped) payloads. Unlike the sidecars, {@code
+ * content} passes through with <b>no</b> escaping of any kind — the framework is a template engine on this
+ * path: the caller pre-sanitizes, this method emits verbatim. {@code content} is for trusted, first-party prose
+ * only (the FG-2 docs-page use case) and MUST NEVER carry live/remote/attacker-influenceable data; a write-path
+ * confirmation/detail body must use a typed/escaped path instead. A build-gating scanner
+ * ({@code RawContentSinkScanner}, this module's test tree) enforces that this framework's own sources never pour a
+ * non-literal (i.e. plausibly live-data-derived) value into {@link Tab#content}/{@link Subtab#content}.
*
* <h5 class='section'>See Also:</h5>
* <ul>
@@ -205,7 +225,11 @@ public class PageTable {
return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
}
- /** Builds one top-level tab's panel: either a leaf view panel, or a sub-tab bar + one sub-panel per subtab. */
+ /**
+ * Builds one top-level tab's panel: a leaf view panel, a leaf raw-content panel, or a (optionally
+ * content-prefaced) sub-tab bar + one sub-panel per subtab — the {@code Tab} panel-body matrix
+ * ({@code {view} | {subtabs} | {content} | {content+subtabs}}, TODO-420) mirrored from {@link Tab#validate()}.
+ */
private static Div buildTabPanel(MarshallingContext ctx, String pageId, Tab t) {
if (t.view != null) {
var body = ViewTable.of(ctx, t.view, null);
@@ -214,6 +238,11 @@ public class PageTable {
var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
+ if (subtabs.isEmpty())
+ // Leaf content-only tab (Tab = {content}): the panel body IS the raw content, emitted verbatim (see
+ // Tab#content's ownership contract - caller sanitizes, this emits as-is).
+ return div(rawText(t.content)).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
+
var subtabBarChildren = new ArrayList<>();
for (var s : subtabs)
subtabBarChildren.add(
@@ -225,7 +254,8 @@ public class PageTable {
var subpanelsChildren = new ArrayList<>();
for (var s : subtabs) {
- var body = ViewTable.of(ctx, s.view, null);
+ // Subtab = {view} | {content} (Subtab#validate()): exactly one of these is non-null here.
+ Object body = s.view != null ? ViewTable.of(ctx, s.view, null) : rawText(s.content);
subpanelsChildren.add(
div(body).class_(SUBPANEL_CLASS).attr(PANEL_TAB_ATTR, t.id).attr(PANEL_SUBTAB_ATTR, s.id));
}
@@ -234,7 +264,16 @@ public class PageTable {
// Tab-scoped only, on purpose: this panel wraps the sub-tab bar and must be visible for EVERY sub-tab, so it
// stays sub-tab-agnostic (see the panel markup contract in this class's javadoc). Do not add
// data-panel-subtab here - it would pin the whole tab to a single sub-tab and blank it for the others.
- return div(subtabBar, subpanels).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
+ //
+ // The optional Tab#content preamble (Tab = {content+subtabs}) is emitted first, inside this SAME outer
+ // panel - so it renders above the sub-tab bar and stays visible for every sub-tab exactly like the bar
+ // itself, with no separate routing attribute (design doc open question 3).
+ var outerChildren = new ArrayList<>();
+ if (t.content != null)
+ outerChildren.add(rawText(t.content));
+ outerChildren.add(subtabBar);
+ outerChildren.add(subpanels);
+ return div(outerChildren.toArray()).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
}
/** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
new file mode 100644
index 0000000000..2fbd46afbe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
@@ -0,0 +1,320 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A per-row action descriptor in the {@code VIEW_META} wire contract (design doc §6.10; the transport-agnostic
+ * "row-action intent" model of {@code TODO-399} Decision 8).
+ *
+ * <p>
+ * A {@link ViewDef} declares zero or more of these via {@link ViewDef#rowActions(RowAction...)}; the
+ * {@code juneau-views.js} runtime renders each as a row-menu item and, on activation, submits the declared
+ * {@link #endpoint} with the declared {@link #method} as a JSON request carrying the process's CSRF token.
+ *
+ * <h5 class='section'>The frozen wire schema</h5>
+ * <p>
+ * This is the <b>complete</b> set of wire fields; it is pinned in full now so that later waves (the declarative
+ * modal/form + typed result of {@code TODO-416}) can rely on it verbatim without forcing another fail-loud
+ * contract bump. Only {@link #id} is required; every other field is optional and is omitted from the serialized
+ * form when unset (null-valued properties are dropped).
+ * <table class='styled'>
+ * <tr><th>Field</th><th>Type / tokens</th><th>Meaning</th></tr>
+ * <tr><td>{@code id}</td><td>string</td><td>Stable action id (menu-item key; also the submit's logical name).</td></tr>
+ * <tr><td>{@code label}</td><td>string</td><td>The menu-item text.</td></tr>
+ * <tr><td>{@code icon}</td><td>string</td><td>Optional glyph name (resolved by the shared icon registry).</td></tr>
+ * <tr><td>{@code endpoint}</td><td>string</td><td>The URL the action submits to.</td></tr>
+ * <tr><td>{@code method}</td><td>{@code POST}|{@code PUT}|{@code PATCH}|{@code DELETE}</td>
+ * <td>The non-safe HTTP method (see {@link Method}).</td></tr>
+ * <tr><td>{@code confirm}</td><td>string</td><td>Optional confirmation prompt shown before the submit.</td></tr>
+ * <tr><td>{@code form}</td><td>string</td><td>Optional form-source URL supplying the action's input fields
+ * (the declarative form {@code TODO-416} renders).</td></tr>
+ * <tr><td>{@code present}</td><td>{@code page}|{@code dialog}|{@code inline}</td>
+ * <td>How the action's form/confirmation is presented (see {@link Present}).</td></tr>
+ * <tr><td>{@code onSuccess}</td><td>{@code redraw}|{@code mergeRow}|{@code navigate}</td>
+ * <td>What the runtime does with a successful result (see {@link OnSuccess}).</td></tr>
+ * </table>
+ *
+ * <h5 class='section'>Why {@link #method} cannot be a safe method</h5>
+ * <p>
+ * {@link Method} enumerates only {@code POST}/{@code PUT}/{@code PATCH}/{@code DELETE}, so a safe (CSRF-exempt)
+ * method cannot be declared at all — a build-time refusal by construction. This mirrors the server boundary:
+ * {@link org.apache.juneau.rest.server.MethodSafety} fails any {@code @Mutating} operation bound to a safe method at
+ * startup, and {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} applies its Origin/CSRF/JSON checks
+ * only to non-safe methods. A mutating action bound to {@code GET} would be a CSRF-able write; the type system
+ * forbids expressing one.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * RowAction <jv>ack</jv> = RowAction.<jsm>create</jsm>(<js>"ack"</js>)
+ * .label(<js>"Acknowledge"</js>)
+ * .endpoint(<js>"servlet:/incidents/{id}/ack"</js>)
+ * .method(RowAction.Method.<jsf>POST</jsf>)
+ * .confirm(<js>"Acknowledge this incident?"</js>)
+ * .present(RowAction.Present.<jsf>DIALOG</jsf>)
+ * .onSuccess(RowAction.OnSuccess.<jsf>MERGE_ROW</jsf>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,icon,endpoint,method,confirm,form,present,onSuccess")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+public class RowAction {
+
+ /**
+ * The non-safe HTTP method a row action submits with.
+ *
+ * <p>
+ * Deliberately limited to the four non-safe methods. A safe method (GET/HEAD/OPTIONS/TRACE) skips the
+ * Origin/CSRF/JSON checks at {@link org.apache.juneau.rest.server.filter.LoopbackBoundary}, so a mutating
+ * action bound to one would be CSRF-able; this enum makes that unexpressible. Each constant's wire token is
+ * its uppercase name, which is exactly what the client sends as the request method.
+ */
+ public enum Method {
+
+ /** HTTP {@code POST}. */
+ POST,
+
+ /** HTTP {@code PUT}. */
+ PUT,
+
+ /** HTTP {@code PATCH}. */
+ PATCH,
+
+ /** HTTP {@code DELETE}. */
+ DELETE;
+
+ /**
+ * Returns the wire token for this method (its uppercase name).
+ *
+ * @return The wire token (e.g. {@code "POST"}).
+ */
+ public String wire() {
+ return name();
+ }
+ }
+
+ /**
+ * How a row action's form/confirmation is presented.
+ *
+ * <p>
+ * Each constant carries the lowercase wire token emitted for the {@code present} field. The behavior behind
+ * each token is implemented by a later wave ({@code TODO-416}); this type freezes the vocabulary.
+ */
+ public enum Present {
+
+ /** Navigate to / render the action's form as a full page. */
+ PAGE("page"),
+
+ /** Render the action's form/confirmation in a modal dialog. */
+ DIALOG("dialog"),
+
+ /** Render the action inline within the row. */
+ INLINE("inline");
+
+ private final String wire;
+
+ Present(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this presentation.
+ *
+ * @return The wire token (e.g. {@code "dialog"}).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
+ /**
+ * What the runtime does with a successful action result.
+ *
+ * <p>
+ * Each constant carries the wire token emitted for the {@code onSuccess} field. The behavior behind each
+ * token is implemented by a later wave ({@code TODO-416}/{@code TODO-417}); this type freezes the vocabulary.
+ */
+ public enum OnSuccess {
+
+ /** Redraw (reload) the whole table. */
+ REDRAW("redraw"),
+
+ /** Merge the returned row into the table in place. */
+ MERGE_ROW("mergeRow"),
+
+ /** Navigate away using the result. */
+ NAVIGATE("navigate");
+
+ private final String wire;
+
+ OnSuccess(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the wire token for this success behavior.
+ *
+ * @return The wire token (e.g. {@code "mergeRow"}).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
+ /** The stable action id (menu-item key; also the submit's logical name). */
+ public String id;
+
+ /** The menu-item text. */
+ public String label;
+
+ /** Optional glyph/icon name (resolved by the shared icon registry). */
+ public String icon;
+
+ /** The URL this action submits to. */
+ public String endpoint;
+
+ /** The non-safe HTTP method wire token (see {@link Method#wire()}). */
+ public String method;
+
+ /** Optional confirmation prompt shown before the submit. */
+ public String confirm;
+
+ /** Optional form-source URL supplying the action's input fields. */
+ public String form;
+
+ /** Optional presentation hint wire token (see {@link Present#wire()}). */
+ public String present;
+
+ /** Optional success-behavior hint wire token (see {@link OnSuccess#wire()}). */
+ public String onSuccess;
+
+ /**
+ * Starts a new {@link RowAction} with the specified stable action id.
+ *
+ * @param id The stable action id. Must not be <jk>null</jk> or blank.
+ * @return A new mutable {@link RowAction} to chain builder calls on.
+ * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
+ */
+ public static RowAction create(String id) {
+ if (id == null || id.isBlank())
+ throw iaex("RowAction id must not be null or blank.");
+ var a = new RowAction();
+ a.id = id;
+ return a;
+ }
+
+ /**
+ * Sets the menu-item text.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RowAction label(String value) {
+ label = value;
+ return this;
+ }
+
+ /**
+ * Sets the glyph/icon name.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RowAction icon(String value) {
+ icon = value;
+ return this;
+ }
+
+ /**
+ * Sets the URL this action submits to.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RowAction endpoint(String value) {
+ endpoint = value;
+ return this;
+ }
+
+ /**
+ * Sets the non-safe HTTP method this action submits with.
+ *
+ * <p>
+ * Only a {@link Method} (POST/PUT/PATCH/DELETE) can be passed, so a safe method can never be declared —
+ * the build-time half of the HIGH-7 safe-method refusal.
+ *
+ * @param value The method. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public RowAction method(Method value) {
+ method = value.wire();
+ return this;
+ }
+
+ /**
+ * Sets the confirmation prompt shown before the submit.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RowAction confirm(String value) {
+ confirm = value;
+ return this;
+ }
+
+ /**
+ * Sets the form-source URL supplying the action's input fields.
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public RowAction form(String value) {
+ form = value;
+ return this;
+ }
+
+ /**
+ * Sets the presentation hint.
+ *
+ * @param value The presentation. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public RowAction present(Present value) {
+ present = value.wire();
+ return this;
+ }
+
+ /**
+ * Sets the success-behavior hint.
+ *
+ * @param value The success behavior. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public RowAction onSuccess(OnSuccess value) {
+ onSuccess = value.wire();
+ return this;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java
new file mode 100644
index 0000000000..5c76d74edd
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SelectionDef.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * The row-selection opt-in for a {@link ViewTable} — the first of {@code TODO-428}'s two INDEPENDENT
+ * opt-ins (design doc §9.3/§6.2; HIGH-5).
+ *
+ * <h5 class='section'>Independent from bulk mutation, by construction</h5>
+ * <p>
+ * Declaring a {@link SelectionDef} adds a per-row checkbox column (and, when {@link #selectAll()} is set, a
+ * select-all header checkbox) to a view table — nothing else. It does <b>not</b>, by any default or
+ * convenience, surface a bulk-mutate control: {@link ViewTable}'s selection-only overloads
+ * ({@link ViewTable#of(ViewDef, java.util.Collection, SelectionDef)}, and the request-bearing sibling) have no code
+ * path that can render one. A view can therefore declare "selectable, for export" while categorically excluding
+ * bulk mutation — e.g. a PagerDuty-style incident table that wants selection for a client-side export/copy
+ * action but must never expose a multi-row mutate. A {@link BulkMutateDef} is a separate, independent type that
+ * <b>consumes</b> a {@link SelectionDef} (it requires one to be constructed at all) but a {@link SelectionDef}
+ * never requires, implies, or references one back.
+ *
+ * <h5 class='section'>Non-wire: never touches {@code VIEW_META} or {@link ViewDef#CONTRACT_VERSION}</h5>
+ * <p>
+ * A {@link SelectionDef} is deliberately <b>not</b> a bean field on {@link ViewDef} and never reaches the
+ * {@code VIEW_META} JSON sidecar or its {@link ViewDef#CONTRACT_VERSION}. {@link ViewTable} instead stamps it as
+ * plain HTML attributes directly on the emitted {@code <table>} — {@link ViewTable#SELECT_ATTR},
+ * {@link ViewTable#ROW_ID_FIELD_ATTR}, {@link ViewTable#SELECT_ALL_ATTR} — mirroring the existing
+ * {@link ViewTable#CSRF_ATTR} auto-embed pattern. The {@code juneau-views.js} runtime reads them straight off the
+ * DOM, so a table with no {@link SelectionDef} carries none of these attributes and renders no selection UI at
+ * all; there is no shared, independently-versioned wire contract to keep in lockstep (contrast
+ * {@link BulkMutateDef}, whose actual bulk-action list <i>is</i> wire data and therefore carries its own,
+ * separate {@link BulkMutateDef#CONTRACT_VERSION}).
+ *
+ * <h5 class='section'>Selection identity is a stable row id, never a DOM/table index (MED-11)</h5>
+ * <p>
+ * {@link #rowIdField()} names the row-data key {@code juneau-views.js} reads to stamp a stable
+ * {@code data-juneau-row-id} attribute on each rendered {@code <tr>} (see {@code createdRow} in that runtime).
+ * Selection state is keyed by that value, never by a row's position in the DOM or in the DataTables draw —
+ * so a poll ({@code TODO-426}), a sort, or a page change can never retarget a selection (or a bulk mutate driven
+ * from one) onto whichever rows now occupy those same positions. The runtime's persistence rule drops any
+ * previously-selected id that is no longer present in the newly drawn row set, so a selection whose rows have
+ * scrolled off screen or off the current page is never kept as a live, actionable target.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Selectable for export, with NO bulk-mutate capability - satisfies the separability condition by construction.</jc>
+ * SelectionDef <jv>selection</jv> = SelectionDef.<jsm>create</jsm>(<js>"id"</js>);
+ * Div <jv>markup</jv> = ViewTable.<jsm>of</jsm>(<jv>req</jv>, <jv>viewDef</jv>, <jv>rows</jv>, <jv>selection</jv>);
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link BulkMutateDef}
+ * <li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class SelectionDef {
+
+ private final String rowIdField;
+ private boolean selectAll = true;
+
+ private SelectionDef(String rowIdField) {
+ this.rowIdField = rowIdField;
+ }
+
+ /**
+ * Starts a new {@link SelectionDef} bound to the specified stable row-id field.
+ *
+ * @param rowIdField The row-data key whose value is this row's stable identity (e.g. {@code "id"} or
+ * {@code "incidentId"}). Must not be <jk>null</jk> or blank.
+ * @return A new {@link SelectionDef}, with {@link #selectAll()} defaulting to <jk>true</jk>.
+ * @throws IllegalArgumentException If {@code rowIdField} is <jk>null</jk> or blank.
+ */
+ public static SelectionDef create(String rowIdField) {
+ if (rowIdField == null || rowIdField.isBlank())
+ throw iaex("SelectionDef rowIdField must not be null or blank.");
+ return new SelectionDef(rowIdField);
+ }
+
+ /**
+ * Sets whether a select-all header checkbox is rendered alongside the per-row checkboxes.
+ *
+ * <p>
+ * Select-all always applies only to the rows currently on screen (the current page's draw) — it can
+ * never reach into rows on another page, which would be indistinguishable from the off-screen-retarget risk
+ * this whole opt-in exists to close.
+ *
+ * @param value The new value. Defaults to <jk>true</jk>.
+ * @return This object.
+ */
+ public SelectionDef selectAll(boolean value) {
+ selectAll = value;
+ return this;
+ }
+
+ /**
+ * The row-data key used as this table's stable row identity.
+ *
+ * @return The row-id field name.
+ */
+ public String rowIdField() {
+ return rowIdField;
+ }
+
+ /**
+ * Whether a select-all header checkbox is rendered.
+ *
+ * @return <jk>true</jk> if select-all is enabled.
+ */
+ public boolean selectAll() {
+ return selectAll;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
index ec43b576f9..185f14f1d3 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
@@ -21,18 +21,27 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import org.apache.juneau.commons.bean.*;
/**
- * A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one child {@link ViewDef}
- * (design doc §"Bean model").
+ * A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one of a child
+ * {@link ViewDef} or raw panel {@link #content} (design doc §"Bean model"; TODO-420).
*
* <p>
* A {@link Subtab} carries a stable {@code id} (the third hash segment, {@code #pageId/tabId/<subtabId>}) and
- * {@code label} (the sub-tab bar button text), plus the referenced {@link #view}. Built via
- * {@link #create(String, String)} + {@link #view(ViewDef)}, mirroring the Phase B builder ergonomics.
+ * {@code label} (the sub-tab bar button text), plus <b>exactly one</b> of {@link #view} or {@link #content}
+ * (matrix: {@code Subtab = {view} | {content}}) — enforced by {@link #validate()}. Built via
+ * {@link #create(String, String)} + {@link #view(ViewDef)}/{@link #content(String)}, mirroring the Phase B builder
+ * ergonomics.
+ *
+ * <h5 class='section'>{@code content}'s ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * See {@link #content} for the full contract. In short: this framework is a template engine on this path —
+ * the <b>caller pre-sanitizes</b>, the framework emits {@link #content} <b>verbatim</b>, unescaped. It exists for
+ * first-party, trusted prose (the FG-2 docs-page use case), never for live/remote/attacker-influenceable data.
*
* <h5 class='section'>See Also:</h5>
* <ul>
* <li class='jc'>{@link PageDef}
* <li class='jc'>{@link Tab}
+ * <li class='jc'>{@link PageTable}
* </ul>
*
* @since 10.0.0
@@ -47,9 +56,21 @@ public class Subtab {
/** The sub-tab bar button text. */
public String label;
- /** The referenced child view. */
+ /** The referenced child view (mutually exclusive with {@link #content}). */
public ViewDef view;
+ /**
+ * Raw panel-body markup for this sub-tab (mutually exclusive with {@link #view}). Emitted <b>verbatim</b> by
+ * {@link PageTable#of(PageDef)} via the html5 {@code rawText(...)} primitive.
+ *
+ * <h5 class='section'>Ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * Identical contract to {@link Tab#content}: the <b>caller sanitizes; the framework emits verbatim</b>.
+ * {@code content} MUST carry trusted, first-party content only, and MUST NOT carry
+ * live/remote/attacker-influenceable data. See {@link Tab#content} for the full threat-model writeup.
+ */
+ public String content;
+
/**
* Starts a new {@link Subtab} builder with the specified stable id and display label.
*
@@ -76,4 +97,29 @@ public class Subtab {
view = value;
return this;
}
+
+ /**
+ * Sets the raw panel-body markup (see {@link #content} for the full ownership contract — trusted /
+ * first-party content only, emitted verbatim).
+ *
+ * @param value The raw markup. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Subtab content(String value) {
+ content = value;
+ return this;
+ }
+
+ /**
+ * Validates this sub-tab in isolation (TODO-420): exactly one of {@link #view} or {@link #content} must be set
+ * (matrix: {@code Subtab = {view} | {content}}).
+ *
+ * @throws IllegalArgumentException On any rule violation.
+ */
+ void validate() {
+ var hasView = view != null;
+ var hasContent = content != null;
+ if (hasView == hasContent)
+ throw iaex("Subtab '%s' must declare exactly one of view or content.", id);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
index b2271b4b76..f828d7232d 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
@@ -23,18 +23,36 @@ import java.util.*;
import org.apache.juneau.commons.bean.*;
/**
- * A single top-level tab within a {@link PageDef}, referencing either one child {@link ViewDef} directly or an
- * ordered list of {@link Subtab} (design doc §"Bean model").
+ * A single top-level tab within a {@link PageDef}, referencing one child {@link ViewDef}, an ordered list of
+ * {@link Subtab}, raw panel {@link #content}, or {@link #content} paired with {@link #subtabs} as a preamble
+ * (design doc §"Bean model"; TODO-420).
*
* <p>
* A {@link Tab} carries a stable {@code id} (the second hash segment, {@code #pageId/<tabId>/...}) and {@code label}
- * (the tab-bar button text), plus <b>exactly one</b> of {@link #view} or {@link #subtabs} — enforced by
- * {@link PageDef#build()}, not by the individual setters, so builder call order stays unconstrained.
+ * (the tab-bar button text), plus a panel body governed by the following matrix — enforced by
+ * {@link #validate()} (called from {@link PageDef#build()}, not by the individual setters, so builder call order
+ * stays unconstrained):
+ *
+ * <h5 class='section'>Panel-body matrix</h5>
+ * <p>
+ * <b>{@code Tab = {view} | {subtabs} | {content} | {content+subtabs}}</b> — exactly one of {@link #view} /
+ * {@link #subtabs} / {@link #content}, <b>except</b> that {@link #content} may co-occur with {@link #subtabs}: in
+ * that combination {@link #content} is a preamble rendered above the sub-tab bar, inside this tab's outer panel
+ * (visible for every sub-tab, no new routing attribute — see {@link PageTable}'s panel markup contract). This
+ * is deliberately not a clean three-way exclusive-or; {@link #view} never combines with anything, but
+ * {@link #content} and {@link #subtabs} do.
+ *
+ * <h5 class='section'>{@code content}'s ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * See {@link #content} for the full contract. In short: this framework is a template engine on this path —
+ * the <b>caller pre-sanitizes</b>, the framework emits {@link #content} <b>verbatim</b>, unescaped. It exists for
+ * first-party, trusted prose (the FG-2 docs-page use case), never for live/remote/attacker-influenceable data.
*
* <h5 class='section'>See Also:</h5>
* <ul>
* <li class='jc'>{@link PageDef}
* <li class='jc'>{@link Subtab}
+ * <li class='jc'>{@link PageTable}
* </ul>
*
* @since 10.0.0
@@ -49,12 +67,41 @@ public class Tab {
/** The tab-bar button text. */
public String label;
- /** The referenced child view (mutually exclusive with {@link #subtabs}). */
+ /** The referenced child view (mutually exclusive with {@link #subtabs} and {@link #content}). */
public ViewDef view;
- /** The ordered sub-tabs (mutually exclusive with {@link #view}). */
+ /** The ordered sub-tabs (mutually exclusive with {@link #view}; may co-occur with {@link #content}). */
public List<Subtab> subtabs;
+ /**
+ * Raw panel-body markup for this tab (mutually exclusive with {@link #view}; may co-occur with {@link #subtabs}
+ * as a preamble rendered above the sub-tab bar — see the panel-body matrix in this class's javadoc).
+ * Emitted <b>verbatim</b> — unescaped — by {@link PageTable#of(PageDef)} via the html5
+ * {@code rawText(...)} primitive (a plain {@code String} child would instead be entity-escaped and would break
+ * markup-bearing prose).
+ *
+ * <h5 class='section'>Ownership contract: template engine, trusted / first-party content only</h5>
+ * <p>
+ * This framework is a <b>template engine</b> on this path: <b>the caller is responsible for sanitizing
+ * {@code content} before setting it; the framework performs no sanitization and emits the string exactly as
+ * given.</b> Nothing in this framework neutralizes {@code <script>}, {@code <style>}, inline event handlers, or
+ * {@code url(...)} sinks in this value — {@link org.apache.juneau.commons.utils.StringUtils#escapeForScript
+ * StringUtils.escapeForScript} is JSON-in/JSON-out and is the wrong tool for an HTML body, and no CSS/script
+ * scanner in this framework inspects consumer-supplied markup.
+ *
+ * <p>
+ * Accordingly, {@code content} MUST carry <b>trusted, first-party content only</b> — markup the
+ * application itself authored or rendered from a trusted, non-attacker-controlled source (e.g. hand-written
+ * page prose, or HTML rendered from a first-party markdown document). {@code content} MUST NOT carry
+ * live/remote/attacker-influenceable data (a request parameter, a third-party API response, any value derived
+ * from something an untrusted party can influence). Pouring such data into this sink is stored XSS in the
+ * page's own trusted origin. A write-path confirmation/detail body built from live data must use a typed/escaped
+ * path instead (an html5 bean tree, or {@code escapeHtml}), never this field — a build-gating scanner
+ * enforces that separation for this framework's own sources (see the {@code RawContentSinkScanner} test-only
+ * guard in this module's test tree).
+ */
+ public String content;
+
/**
* Starts a new {@link Tab} builder with the specified stable id and display label.
*
@@ -94,23 +141,43 @@ public class Tab {
}
/**
- * Validates this tab in isolation (design doc §"Bean model" validation rules): exactly one of {@link #view}/
- * {@link #subtabs} must be set, and sub-tab ids must be unique <b>within this tab</b> (a sub-tab id may safely
- * recur under a different tab — the hash's {@code tabId} segment already disambiguates the parent before
- * {@code subtabId} is ever resolved).
+ * Sets the raw panel-body markup (see {@link #content} for the full ownership contract — trusted /
+ * first-party content only, emitted verbatim).
+ *
+ * @param value The raw markup. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Tab content(String value) {
+ content = value;
+ return this;
+ }
+
+ /**
+ * Validates this tab in isolation (design doc §"Bean model" validation rules; TODO-420 widens this from a
+ * three-way exclusive-or to the panel-body matrix documented in this class's javadoc):
+ * {@code {view} | {subtabs} | {content} | {content+subtabs}}. {@link #view} is exclusive of everything else;
+ * {@link #content} and {@link #subtabs} may co-occur (the preamble case). Every declared sub-tab is also
+ * validated in isolation (see {@link Subtab#validate()}), and sub-tab ids must be unique <b>within this tab</b>
+ * (a sub-tab id may safely recur under a different tab — the hash's {@code tabId} segment already
+ * disambiguates the parent before {@code subtabId} is ever resolved).
*
* @throws IllegalArgumentException On any rule violation.
*/
void validate() {
var hasView = view != null;
var hasSubtabs = subtabs != null && !subtabs.isEmpty();
- if (hasView == hasSubtabs)
- throw iaex("Tab '%s' must declare exactly one of view or subtabs.", id);
+ var hasContent = content != null;
+ if (hasView) {
+ if (hasSubtabs || hasContent)
+ throw iaex("Tab '%s' must declare exactly one of view, subtabs, or content "
+ + "(view cannot combine with subtabs or content).", id);
+ } else if (!hasSubtabs && !hasContent) {
+ throw iaex("Tab '%s' must declare exactly one of view, subtabs, or content.", id);
+ }
if (hasSubtabs) {
var ids = new HashSet<String>();
for (var s : subtabs) {
- if (s.view == null)
- throw iaex("Subtab '%s' in tab '%s' must declare a view.", s.id, id);
+ s.validate();
if (!ids.add(s.id))
throw iaex("Tab '%s': duplicate subtab id '%s'.", id, s.id);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
index b096a36cc5..c944676831 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
@@ -35,10 +35,10 @@ import org.apache.juneau.rest.server.datatables.*;
* sidecar the server writes and the {@code juneau-views.js} runtime consumes stay wire-stable.
*
* <p>
- * {@code details} (the row-details expander's field list) is implemented; see {@link #details(DetailDef...)}.
- * The remaining reserved row-action/catalog fields ({@code rowActions}, {@code catalog}) are <b>not</b> part of this
- * MVP builder and are therefore omitted from the serialized contract (design doc §6.10 reserved stubs) —
- * omitted, not emitted as {@code null}.
+ * {@code details} (the row-details expander's field list) and {@code rowActions} (the per-row action menu; see
+ * {@link #rowActions(RowAction...)}) are both implemented. The remaining reserved catalog fields ({@code catalog},
+ * {@code format}, ...) are <b>not</b> part of this MVP builder and are therefore omitted from the serialized
+ * contract (design doc §6.10 reserved stubs) — omitted, not emitted as {@code null}.
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
@@ -56,16 +56,17 @@ import org.apache.juneau.rest.server.datatables.*;
* <li class='jc'>{@link Column}
* <li class='jc'>{@link RibbonAction}
* <li class='jc'>{@link RowClassRule}
+ * <li class='jc'>{@link RowAction}
* </ul>
*
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,details,pollIntervalMs")
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,rowActions,details,pollIntervalMs")
@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class ViewDef {
/** The frozen contract version. Bumped only on a breaking wire change. */
- public static final String CONTRACT_VERSION = "2";
+ public static final String CONTRACT_VERSION = "3";
/**
* The minimum honored polling interval, in milliseconds.
@@ -238,6 +239,16 @@ public class ViewDef {
/** The declarative row-decorator rules. */
public List<RowClassRule> rowClassRules;
+ /**
+ * The per-row action descriptors, in menu order; omitted from the wire when unset (no row menu).
+ *
+ * <p>
+ * Each {@link RowAction} declares a mutating request the {@code juneau-views.js} runtime renders as a row-menu
+ * item and submits with the auto-embedded CSRF token (see {@link RowAction} for the frozen wire schema and
+ * the fail-closed submit contract).
+ */
+ public List<RowAction> rowActions;
+
/**
* The row-details expander's field list; omitted from the wire when unset (no expander).
*
@@ -410,6 +421,22 @@ public class ViewDef {
return this;
}
+ /**
+ * Declares the per-row action menu.
+ *
+ * <p>
+ * Each {@link RowAction} is a mutating request the {@code juneau-views.js} runtime renders as a row-menu item
+ * and submits with the process's CSRF token. A {@link RowAction} can only carry a non-safe HTTP method
+ * ({@link RowAction.Method}), so a mutating action can never be declared against a safe (CSRF-exempt) method.
+ *
+ * @param value The row actions, in menu order.
+ * @return This object.
+ */
+ public ViewDef rowActions(RowAction...value) {
+ rowActions = l(value);
+ return this;
+ }
+
/**
* Finalizes the builder and returns the wire-ready {@link ViewDef}.
*
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 10b7ccc684..272c6fb85b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -17,6 +17,7 @@
package org.apache.juneau.rest.server.views;
import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
import java.util.*;
@@ -25,6 +26,9 @@ import org.apache.juneau.bean.html5.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.filter.*;
+
+import jakarta.servlet.http.*;
/**
* Builds the HTML delivery shell for a {@link ViewDef} — the {@code data-juneau-view} table plus the
@@ -80,6 +84,40 @@ public class ViewTable {
/** Prefix of the sidecar {@code <script>} element id: {@code juneau-view:<viewId>}. */
public static final String SIDECAR_ID_PREFIX = "juneau-view:";
+ /**
+ * Attribute the auto-embedded CSRF token is stamped into on the emitted {@code <table>}.
+ *
+ * <p>
+ * On an allowed request the {@link LoopbackBoundaryFilter} publishes the process's CSRF token under
+ * {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE}; the request-bearing {@code of(...)} overloads read it and
+ * stamp it here so the {@code juneau-views.js} row-action submit can attach it without the host having to
+ * remember a step. A host may also set this attribute (or a documented init call) itself as an
+ * override/fallback. The runtime <b>fails closed</b> when this attribute is absent, empty, or whitespace: it
+ * visibly refuses to issue any row-action request rather than sending one the server would 403.
+ */
+ public static final String CSRF_ATTR = "data-juneau-csrf";
+
+ /**
+ * Marker attribute stamped when a {@link SelectionDef} is declared ({@code TODO-428}). Pure DOM signaling
+ * — never part of the {@code VIEW_META} wire contract; see {@link SelectionDef}'s class javadoc.
+ */
+ public static final String SELECT_ATTR = "data-juneau-select";
+
+ /** Attribute carrying {@link SelectionDef#rowIdField()} — the row-data key the runtime stamps as each row's stable id. */
+ public static final String ROW_ID_FIELD_ATTR = "data-juneau-row-id-field";
+
+ /** Attribute carrying {@code "1"}/{@code "0"} for {@link SelectionDef#selectAll()}. */
+ public static final String SELECT_ALL_ATTR = "data-juneau-select-all";
+
+ /**
+ * Marker attribute stamped when a {@link BulkMutateDef} is declared ({@code TODO-428}); pairs with the
+ * {@link #BULK_SIDECAR_ID_PREFIX} sidecar carrying the actual bulk-action list.
+ */
+ public static final String BULK_ATTR = "data-juneau-bulk";
+
+ /** Prefix of the bulk-actions sidecar {@code <script>} element id: {@code juneau-view-bulk:<viewId>}. */
+ public static final String BULK_SIDECAR_ID_PREFIX = "juneau-view-bulk:";
+
private ViewTable() {}
/**
@@ -105,6 +143,128 @@ public class ViewTable {
return of(MarshallingContext.DEFAULT, viewDef, rows);
}
+ /**
+ * Builds the view-table shell for a server-side view, auto-embedding the request's CSRF token so a declared
+ * row action can submit with it.
+ *
+ * <p>
+ * The token is read from {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} — the value the boundary filter
+ * stamps on every allowed request — and stamped into {@link #CSRF_ATTR} on the emitted {@code <table>}.
+ * When the request carries no such token (no boundary filter in front of this application, or the attribute is
+ * blank), no attribute is emitted and the runtime fails closed on any row-action attempt. This is the
+ * auto-embed entry point of the token contract; a {@link #CSRF_ATTR} the host sets itself is the
+ * override/fallback.
+ *
+ * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the token.
+ * Can be <jk>null</jk> (no token embedded).
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>} and the JSON sidecar.
+ */
+ public static Div of(HttpServletRequest req, ViewDef viewDef) {
+ return of(MarshallingContext.DEFAULT, viewDef, null, csrfToken(req));
+ }
+
+ /**
+ * Builds the view-table shell and renders {@code rows}, auto-embedding the request's CSRF token.
+ *
+ * <p>
+ * The request-bearing counterpart of {@link #of(ViewDef, Collection)}; see {@link #of(HttpServletRequest, ViewDef)}
+ * for the token-embed and fail-closed contract.
+ *
+ * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the token.
+ * Can be <jk>null</jk> (no token embedded).
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+ * JSON sidecar.
+ */
+ public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows) {
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req));
+ }
+
+ /**
+ * Builds the view-table shell with row selection enabled (design doc §9.3; {@code TODO-428}), auto-embedding
+ * the request's CSRF token.
+ *
+ * <p>
+ * This overload has no code path that can render a bulk-mutate control — a {@link SelectionDef} alone
+ * can only ever add per-row checkboxes (and, per {@link SelectionDef#selectAll()}, a select-all header
+ * checkbox). Use {@link #of(HttpServletRequest, ViewDef, Collection, BulkMutateDef)} when bulk mutation is
+ * also required (that overload requires its own {@link SelectionDef}, supplied via
+ * {@link BulkMutateDef#create(WritePermit, SelectionDef)}).
+ *
+ * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the CSRF
+ * token. Can be <jk>null</jk> (no token embedded).
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param selection The selection opt-in. Must not be <jk>null</jk> (use one of the other overloads for a table
+ * with no selection).
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view data-juneau-select>}, its VIEW_META
+ * sidecar, and no bulk-actions sidecar.
+ */
+ public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, SelectionDef selection) {
+ if (selection == null)
+ throw iaex("selection must not be null; use one of the other of(...) overloads for a table with no selection.");
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), selection, null);
+ }
+
+ /**
+ * Builds the view-table shell with row selection AND bulk mutation enabled (design doc §9.3; {@code
+ * TODO-428}), auto-embedding the request's CSRF token.
+ *
+ * <p>
+ * The selection this table renders is {@link BulkMutateDef#selection()} — the one {@code bulkMutate} was
+ * itself constructed against ({@link BulkMutateDef#create(WritePermit, SelectionDef)} requires one) — so
+ * there is exactly one {@link SelectionDef} in play and no way for it to disagree with what the bulk actions
+ * target.
+ *
+ * @param req The current request, whose {@link LoopbackBoundaryFilter#TOKEN_ATTRIBUTE} supplies the CSRF
+ * token. Can be <jk>null</jk> (no token embedded).
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param bulkMutate The bulk-mutate opt-in. Must not be <jk>null</jk> (use the {@link SelectionDef} overload,
+ * or one of the pre-{@code TODO-428} overloads, for a table with no bulk mutation).
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view data-juneau-select data-juneau-bulk>},
+ * its VIEW_META sidecar, AND the independently-versioned bulk-actions sidecar.
+ */
+ public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, BulkMutateDef bulkMutate) {
+ if (bulkMutate == null)
+ throw iaex("bulkMutate must not be null; use the SelectionDef overload for selection without bulk mutation.");
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), bulkMutate.selection(), bulkMutate);
+ }
+
+ /**
+ * Builds the view-table shell with row selection enabled, using the default marshalling context and no CSRF
+ * token embed (test/no-request convenience; see {@link #of(HttpServletRequest, ViewDef, Collection, SelectionDef)}
+ * for the auto-embedding counterpart).
+ *
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param selection The selection opt-in. Must not be <jk>null</jk>.
+ * @return A new {@link Div} as described above.
+ */
+ public static Div of(ViewDef viewDef, Collection<?> rows, SelectionDef selection) {
+ if (selection == null)
+ throw iaex("selection must not be null; use one of the other of(...) overloads for a table with no selection.");
+ return of(MarshallingContext.DEFAULT, viewDef, rows, null, selection, null);
+ }
+
+ /**
+ * Builds the view-table shell with row selection AND bulk mutation enabled, using the default marshalling
+ * context and no CSRF token embed (test/no-request convenience; see
+ * {@link #of(HttpServletRequest, ViewDef, Collection, BulkMutateDef)} for the auto-embedding counterpart).
+ *
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param bulkMutate The bulk-mutate opt-in. Must not be <jk>null</jk>.
+ * @return A new {@link Div} as described above.
+ */
+ public static Div of(ViewDef viewDef, Collection<?> rows, BulkMutateDef bulkMutate) {
+ if (bulkMutate == null)
+ throw iaex("bulkMutate must not be null; use the SelectionDef overload for selection without bulk mutation.");
+ return of(MarshallingContext.DEFAULT, viewDef, rows, null, bulkMutate.selection(), bulkMutate);
+ }
+
/**
* Builds the view-table shell using the specified marshalling context for bean-property cell reads.
*
@@ -115,11 +275,72 @@ public class ViewTable {
* JSON sidecar.
*/
public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows) {
+ return of(ctx, viewDef, rows, null);
+ }
+
+ /**
+ * Builds the view-table shell, optionally stamping a pre-resolved CSRF token onto the emitted {@code <table>}.
+ *
+ * <p>
+ * The shared core the request-bearing and context-only overloads delegate to. A non-blank {@code csrfToken}
+ * is stamped into {@link #CSRF_ATTR}; a blank or {@code null} token stamps nothing, leaving the runtime to
+ * fail closed on any row-action attempt.
+ *
+ * @param ctx The marshalling context used to read bean-property cell values. Must not be <jk>null</jk>.
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param csrfToken The CSRF token to embed, or <jk>null</jk>/blank to embed none.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+ * JSON sidecar.
+ */
+ public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken) {
+ return of(ctx, viewDef, rows, csrfToken, null, null);
+ }
+
+ /**
+ * Builds the view-table shell, optionally stamping a CSRF token, a {@link SelectionDef}, and/or a
+ * {@link BulkMutateDef} — the shared core every public overload (pre- and post-{@code TODO-428})
+ * ultimately delegates to.
+ *
+ * <p>
+ * {@code selection}/{@code bulkMutate} are independent opt-ins (design doc §9.3; HIGH-5): passing
+ * {@code selection} alone renders per-row checkboxes and nothing else — there is no branch below that
+ * can render a bulk-mutate control from {@code selection} alone. When {@code bulkMutate} is non-<jk>null</jk>,
+ * its own {@link BulkMutateDef#selection()} is what selection-related markup is rendered from; a caller-passed
+ * {@code selection} that is not that SAME instance is rejected, so the two can never silently disagree about
+ * which rows a bulk action targets.
+ *
+ * @param ctx The marshalling context used to read bean-property cell values. Must not be <jk>null</jk>.
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param csrfToken The CSRF token to embed, or <jk>null</jk>/blank to embed none.
+ * @param selection The selection opt-in, or <jk>null</jk> for none. When {@code bulkMutate} is non-<jk>null</jk>,
+ * must be either <jk>null</jk> or exactly {@code bulkMutate.selection()}.
+ * @param bulkMutate The bulk-mutate opt-in, or <jk>null</jk> for none.
+ * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, the
+ * VIEW_META sidecar, and — only when {@code bulkMutate} is supplied — the independently-versioned
+ * bulk-actions sidecar.
+ * @throws IllegalArgumentException If {@code selection} and {@code bulkMutate} are both non-<jk>null</jk> but
+ * {@code selection} is not {@code bulkMutate.selection()}.
+ */
+ public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken,
+ SelectionDef selection, BulkMutateDef bulkMutate) {
+ if (bulkMutate != null) {
+ if (selection != null && selection != bulkMutate.selection())
+ throw iaex("selection must be exactly bulkMutate.selection() when both are supplied; "
+ + "a BulkMutateDef can only render the SelectionDef it was constructed against.");
+ selection = bulkMutate.selection();
+ }
+
var id = viewDef.id;
var cols = viewDef.columns == null ? List.<Column>of() : viewDef.columns;
- // <thead> of column titles (falling back to the data key when no title was set).
- var headerCells = new ArrayList<>(cols.size());
+ // <thead> of column titles (falling back to the data key when no title was set); a selection opt-in
+ // prepends a dedicated, unlabeled leading header cell for the checkbox column (never sharing the leading
+ // cell with any other affordance - TODO-428 Q3/owner decision).
+ var headerCells = new ArrayList<>(cols.size() + 1);
+ if (selection != null)
+ headerCells.add(th().attr("class", "juneau-view-select-th").attr("aria-label", "Select"));
for (var c : cols)
headerCells.add(th(c.title == null ? c.data : c.title));
@@ -129,7 +350,9 @@ public class ViewTable {
if (rows != null) {
var bodyRows = new ArrayList<>(rows.size());
for (var row : rows) {
- var cells = new ArrayList<>(cols.size());
+ var cells = new ArrayList<>(cols.size() + 1);
+ if (selection != null)
+ cells.add(td().attr("class", "juneau-view-select-cell"));
for (var c : cols) {
var v = value(ctx, row, c.data);
cells.add(td(v == null ? "" : v));
@@ -141,11 +364,46 @@ public class ViewTable {
var table = table(tableChildren.toArray()).id(id).attr(MARKER_ATTR, id);
+ // Auto-embed the CSRF token (MED-10/HIGH-1) so a row-action submit can attach it; a blank token stamps
+ // nothing, so the runtime fails closed rather than shipping an empty header the boundary would 403.
+ if (csrfToken != null && ! csrfToken.isBlank())
+ table.attr(CSRF_ATTR, csrfToken);
+
+ // Selection opt-in: pure DOM-attribute signaling (never VIEW_META/ViewDef.CONTRACT_VERSION - see
+ // SelectionDef's class javadoc). Absent entirely when selection is null, so juneau-views.js renders no
+ // checkbox column at all for an ordinary table - the separability guarantee's "off by default" half.
+ if (selection != null) {
+ table.attr(SELECT_ATTR, "1");
+ table.attr(ROW_ID_FIELD_ATTR, selection.rowIdField());
+ table.attr(SELECT_ALL_ATTR, selection.selectAll() ? "1" : "0");
+ }
+
// Sidecar: serialize the VIEW_META, neutralize script break-outs, then insert as RAW content (class javadoc).
var json = escapeForScript(Json.of(viewDef));
var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
- return div(table, sidecar);
+ var children = new ArrayList<>();
+ children.add(table);
+
+ // Bulk-mutate opt-in: its OWN independently-versioned sidecar (BulkMutateDef.CONTRACT_VERSION), never
+ // merged into VIEW_META - a version bump here can never force a ViewDef.CONTRACT_VERSION bump (R2).
+ // Absent entirely when bulkMutate is null, so an ordinary or selection-only table carries no bulk affordance.
+ if (bulkMutate != null) {
+ table.attr(BULK_ATTR, "1");
+ var bulkJson = escapeForScript(Json.of(bulkMutate));
+ children.add(script().type("application/json").id(BULK_SIDECAR_ID_PREFIX + id).text(rawText(bulkJson)));
+ }
+
+ children.add(sidecar);
+ return div(children.toArray());
+ }
+
+ /** Reads the boundary-stamped CSRF token off the request, or {@code null} when absent. */
+ private static String csrfToken(HttpServletRequest req) {
+ if (req == null)
+ return null;
+ var v = req.getAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE);
+ return v == null ? null : v.toString();
}
/** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java
new file mode 100644
index 0000000000..8976ed4c88
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/WritePermit.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * The views-toolkit's own structural gate token for {@link BulkMutateDef}: proof that the caller's own write-gate
+ * was consulted before a bulk-mutating affordance is configured (design doc §9.3; {@code TODO-428}'s HIGH-5
+ * separability guarantee).
+ *
+ * <h5 class='section'>Why this exists, and why it is NOT the console's own {@code WritePermit}</h5>
+ * <p>
+ * A consuming application (e.g. the Sandbox Support Console) may already have its own, much richer write-gate
+ * token — one bound to a journal, a box {@code Mode}, an idempotency key, and a target validator (see that
+ * application's own {@code write.WritePermit}/{@code WriteGuard}). That type is deliberately <b>not</b> reused
+ * here: its constructor is package-private to its own {@code write} package, it is defined in a console-ui
+ * artifact this framework module must not depend on, and its shape carries concerns (journal binding, SAFE/LIVE
+ * mode) that are that application's business, not this framework's. Reaching into it would be a layering
+ * violation in the wrong direction (framework depending on a specific consumer).
+ *
+ * <p>
+ * This type is therefore this module's own, minimal, independent token — the same <i>discipline</i>
+ * (a required-parameter capability proof), a deliberately smaller <i>shape</i> (no journal/mode/idempotency
+ * concerns, which belong to the consumer's own gate). A consuming application's own write-gate is expected to
+ * mint one (typically by wrapping/adapting its own gate result) only after ITS OWN checks have passed, then hand
+ * it to {@link BulkMutateDef#create(WritePermit, SelectionDef)}. This module does not, and cannot, verify that the
+ * caller's gate logic is correct — that is out of scope for a UI-rendering toolkit — but it does
+ * guarantee, by construction, that {@link BulkMutateDef} cannot be reached at all without a permit in hand: there
+ * is no zero-argument overload, so omitting one is a <b>compile</b> failure, not a missed javadoc sentence
+ * (mirroring the discipline of the console's own {@code WriteGuard}, source design §5.4).
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// After the consumer's OWN write-gate has already approved this capability:</jc>
+ * WritePermit <jv>permit</jv> = WritePermit.<jsm>forCapability</jsm>(<js>"incident:bulk-ack"</js>);
+ * BulkMutateDef <jv>bulk</jv> = BulkMutateDef.<jsm>create</jsm>(<jv>permit</jv>, <jv>selection</jv>)
+ * .actions(RowAction.<jsm>create</jsm>(<js>"ack"</js>).endpoint(<js>"servlet:/incidents/{id}/ack"</js>).method(RowAction.Method.<jsf>POST</jsf>));
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link BulkMutateDef}
+ * <li class='jc'>{@link SelectionDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public final class WritePermit {
+
+ private final String capability;
+
+ private WritePermit(String capability) {
+ this.capability = capability;
+ }
+
+ /**
+ * Mints a permit for the named capability.
+ *
+ * <p>
+ * This is a bare proof-of-authorization token, not an enforcement seam: minting one asserts that the CALLER's
+ * own write-gate has already approved {@code capability}. This module trusts that assertion — it has no
+ * journal, arming, or idempotency logic of its own to check it against — its only job is to make the
+ * bulk-mutate opt-in unreachable without one.
+ *
+ * @param capability A stable, human-readable identifier for the capability this permit authorizes (e.g.
+ * {@code "incident:bulk-ack"}). Must not be <jk>null</jk> or blank.
+ * @return A new {@link WritePermit}.
+ * @throws IllegalArgumentException If {@code capability} is <jk>null</jk> or blank.
+ */
+ public static WritePermit forCapability(String capability) {
+ if (capability == null || capability.isBlank())
+ throw iaex("WritePermit capability must not be null or blank.");
+ return new WritePermit(capability);
+ }
+
+ /**
+ * The capability this permit authorizes.
+ *
+ * @return The capability identifier passed to {@link #forCapability(String)}.
+ */
+ public String capability() {
+ return capability;
+ }
+
+ @Override /* Object */
+ public String toString() {
+ return "WritePermit(capability=" + capability + ")";
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
index ef61550da6..6c84011944 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -54,7 +54,7 @@
// Contract-version handshake for PAGE_META: MUST equal PageDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION
// (single source of truth on the server, itself reusing ViewDef.CONTRACT_VERSION). Distinct from - and does
// not replace - the per-view VIEW_META handshake juneau-views.js's initTable(...) already performs.
- const JUNEAU_PAGE_CONTRACT_VERSION = "2";
+ const JUNEAU_PAGE_CONTRACT_VERSION = "3";
const NS = window.JuneauViews = window.JuneauViews || {};
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index 8494751008..9f1a8b414a 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -435,3 +435,128 @@ table.dataTable > tbody > tr > td {
.juneau-view-detail-panel dd {
margin: 0 0 0.35em 42%;
}
+
+/*
+ * Declarative row-action modal (present=dialog) + settled-outcome banner (juneau-views.js openActionDialog/
+ * buildDialogOverlay/renderActionOutcome, TODO-416/417). NEUTRAL - shape/layout only, no palette color; a host's
+ * chrome.css MAY theme these same classes. The confirmation body is typed fields painted with textContent (never
+ * innerHTML), so there is no markup styling here to get wrong.
+ */
+.juneau-view-dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 1000;
+}
+
+.juneau-view-dialog {
+ min-width: 20em;
+ max-width: 90vw;
+ max-height: 90vh;
+ overflow: auto;
+ padding: 1em 1.25em;
+ border: 1px solid;
+ border-radius: 6px;
+ background: #fff;
+}
+
+.juneau-view-dialog-title {
+ margin: 0 0 0.75em;
+ font-size: 1.1em;
+}
+
+.juneau-view-dialog-fields {
+ margin: 0 0 1em;
+}
+
+.juneau-view-dialog-fields dt {
+ float: left;
+ clear: left;
+ width: 40%;
+ font-weight: 600;
+}
+
+.juneau-view-dialog-fields dd {
+ margin: 0 0 0.35em 42%;
+}
+
+.juneau-view-dialog-actions {
+ display: flex;
+ gap: 0.5em;
+ justify-content: flex-end;
+}
+
+/* The post-response settled-outcome banner (success/failure/refusal/unknown) - neutral; role drives assistive text. */
+.juneau-view-action-outcome {
+ margin-top: 0.35em;
+ font-size: 0.9em;
+}
+
+/*
+ * The LIVE async-job progress banner (TODO-425) - shown while a long job streams over SSE. Deliberately a
+ * DISTINCT class from .juneau-view-action-outcome: a running job and a settled outcome are never the same visual
+ * state. The row it sits in carries `data-juneau-job` (never `data-juneau-inflight`), so table polling keeps
+ * running for the whole job (HIGH-9).
+ */
+.juneau-view-job-progress {
+ margin-top: 0.35em;
+ font-size: 0.9em;
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.juneau-view-job-progress-msg {
+ flex: 1 1 auto;
+}
+
+.juneau-view-job-cancel {
+ flex: 0 0 auto;
+}
+
+/*
+ * Row selection + bulk mutation (TODO-428) - two INDEPENDENT opt-ins. NEUTRAL - shape only, no palette color; a
+ * host's chrome.css MAY theme these same classes. The selection column itself (`.juneau-view-select-th`/
+ * `.juneau-view-select-cell`) is rendered whenever a `SelectionDef` was declared, WITH or WITHOUT bulk mutation;
+ * the bulk toolbar (`.juneau-view-bulk-toolbar`) only ever exists when a `BulkMutateDef` was ALSO declared - so a
+ * selection-only (e.g. selectable-for-export) table gets the checkbox column and nothing else here.
+ */
+.juneau-view-select-th,
+.juneau-view-select-cell {
+ width: 2.25em;
+ text-align: center;
+}
+
+.juneau-view-select-checkbox,
+.juneau-view-select-all-checkbox {
+ cursor: pointer;
+}
+
+/* The bulk-actions toolbar - inserted into the unified toolbar row's LEFT cluster, after the paging pill. */
+.juneau-view-bulk-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+.juneau-view-bulk-count {
+ font-size: 0.8125rem;
+ white-space: nowrap;
+}
+
+.juneau-view-bulk-action-btn {
+ padding: 0.3em 0.75em;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.25em;
+ background: transparent;
+ font-size: 0.8125rem;
+ cursor: pointer;
+}
+
+.juneau-view-bulk-action-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 12c95177ac..b5f085b4ca 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -38,10 +38,45 @@
// Contract-version handshake: MUST equal ViewDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION (single source
// of truth on the server). The initializer fails loud when a sidecar's contractVersion differs.
- const JUNEAU_VIEW_CONTRACT_VERSION = "2";
+ const JUNEAU_VIEW_CONTRACT_VERSION = "3";
+
+ // The typed action-result contract version (ActionResult.CONTRACT_VERSION on the server). This is a SEPARATE,
+ // independently-versioned wire contract from VIEW_META - it is deliberately NOT aliased to
+ // JUNEAU_VIEW_CONTRACT_VERSION, so the row-action submit result and the view sidecar version independently. A
+ // 2xx action-result whose contractVersion differs is rendered as a visible, non-optimistic UNKNOWN rather than
+ // silently mis-read.
+ const JUNEAU_ACTION_RESULT_CONTRACT_VERSION = "1";
+
+ /**
+ * The bulk-mutate-actions contract version (BulkMutateDef.CONTRACT_VERSION on the server; {@code TODO-428}).
+ * A THIRD, independently-versioned wire contract - deliberately not aliased to either
+ * JUNEAU_VIEW_CONTRACT_VERSION or JUNEAU_ACTION_RESULT_CONTRACT_VERSION, so a bulk-actions-list revision can
+ * never force a VIEW_META (ViewDef) contract bump (R2/design-doc guard). A sidecar whose contractVersion
+ * differs is refused (fail-loud), leaving row selection itself fully functional - only the bulk toolbar is
+ * withheld, per the two-independent-opt-ins separability guarantee (HIGH-5).
+ */
+ const JUNEAU_BULK_CONTRACT_VERSION = "1";
+
+ /**
+ * TODO-428 selection/bulk DOM attribute names - MUST equal ViewTable's constants of the same names
+ * (SELECT_ATTR, ROW_ID_FIELD_ATTR, SELECT_ALL_ATTR, BULK_ATTR, BULK_SIDECAR_ID_PREFIX) on the server. Selection
+ * and bulk-mutation are two INDEPENDENT opt-ins (HIGH-5): a table carries SELECT_ATTR with or without
+ * BULK_ATTR, but never the reverse (ViewTable.of(..., BulkMutateDef) always stamps both, since a
+ * BulkMutateDef is only constructible against a SelectionDef it requires at compile time). All of this is
+ * pure DOM-attribute signaling - none of it is part of the VIEW_META wire contract (R2 guard; see
+ * SelectionDef/BulkMutateDef's class javadocs on the server).
+ */
+ const SELECT_ATTR = "data-juneau-select";
+ const ROW_ID_ATTR = "data-juneau-row-id";
+ const ROW_ID_FIELD_ATTR = "data-juneau-row-id-field";
+ const SELECT_ALL_ATTR = "data-juneau-select-all";
+ const BULK_ATTR = "data-juneau-bulk";
+ const BULK_SIDECAR_ID_PREFIX = "juneau-view-bulk:";
const NS = window.JuneauViews = window.JuneauViews || {};
NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
+ NS.ACTION_RESULT_CONTRACT_VERSION = JUNEAU_ACTION_RESULT_CONTRACT_VERSION;
+ NS.BULK_CONTRACT_VERSION = JUNEAU_BULK_CONTRACT_VERSION;
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
@@ -121,6 +156,222 @@
return out;
}
+ /**
+ * Resolves a row's STABLE selection id (MED-11) from its data, via the `rowIdField` key the host declared in
+ * its {@code SelectionDef} (carried client-side only via the {@code ROW_ID_FIELD_ATTR} DOM attribute - never
+ * VIEW_META). Deliberately NEVER a DOM/table index: a poll/sort/page tick reshuffles which data occupies
+ * which row index, but this always resolves to the SAME id for the SAME underlying record. Returns
+ * `undefined` when `rowData`/`rowIdField` is absent, or when the row data has no such key - callers must
+ * treat that as "this row cannot be selected" rather than inventing a fallback identity (e.g. a DOM index).
+ */
+ function rowIdOf(rowData, rowIdField) {
+ return (rowData && rowIdField) ? rowData[rowIdField] : undefined;
+ }
+
+ /**
+ * The persistence rule (Q2/MED-11): given the currently-selected ids and the ids actually present in the
+ * latest draw (sort/page/poll), returns the subset of `selectedIds` that are STILL present - silently
+ * DROPPING any id that has left the current page/result set, so a selection can never be kept "live" for a
+ * row that is no longer on screen (closing the "bulk action hits the wrong target" case). Pure - no DOM, no
+ * Set/Map identity assumptions on the caller's side (plain arrays in, plain array out); ids are compared as
+ * strings so a numeric id and its string form are never silently treated as different rows.
+ */
+ function pruneSelection(selectedIds, currentIds) {
+ const present = {};
+ (currentIds || []).forEach(function (id) { if (id != null) present[String(id)] = true; });
+ const out = [];
+ (selectedIds || []).forEach(function (id) { if (id != null && Object.hasOwn(present, String(id))) out.push(id); });
+ return out;
+ }
+
+ /**
+ * The default CSRF header name - MUST equal {@code LoopbackBoundary.DEFAULT_CSRF_HEADER} ("X-Csrf-Token") so the
+ * runtime and the server boundary agree by default. A host may override the name per-table (see
+ * resolveCsrfHeaderName in the binding layer); absent an override the runtime sends this one.
+ */
+ const DEFAULT_CSRF_HEADER = "X-Csrf-Token";
+
+ /**
+ * The safe (non-state-changing) HTTP methods - mirrors {@code MethodSafety.SAFE_METHODS}. A row action bound to
+ * one of these would skip Origin/CSRF/JSON at the server boundary (a CSRF-able write), so the runtime refuses to
+ * issue it. The Java builder ({@code RowAction.Method}) already makes a safe method unexpressible; this is the
+ * client half of that same HIGH-7 refusal, for a hand-edited or stale sidecar.
+ */
+ const SAFE_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1 };
+
+ /** Whether a method is safe (GET/HEAD/OPTIONS/TRACE) - null/absent is NOT safe (mirrors MethodSafety.isSafe). */
+ function isSafeMethod(method) {
+ return !!(method && Object.hasOwn(SAFE_METHODS, String(method).toUpperCase()));
+ }
+
+ /**
+ * The single, shared fail-closed token test. Absent (null/undefined), present-but-empty, and
+ * present-but-WHITESPACE all count as blank - matching the actual control, {@code LoopbackBoundary.check}'s
+ * `presented == null || presented.isBlank()` (NOT `SynchronizerToken.matches`'s `isEmpty`, which would let a
+ * whitespace token through to a confusing server 403). Client refusal here is defense-against-omission; the
+ * landed server-side {@code LoopbackBoundary} is the real security boundary.
+ */
+ function isBlankToken(v) {
+ return v == null || String(v).trim() === "";
+ }
+
+ /**
+ * Builds the fail-closed row-action request descriptor from a RowAction intent, a token, and a header name -
+ * pure, DOM/fetch-free (plain data in, plain data out) so the fail-closed contract is unit-testable without a
+ * browser. Returns EITHER a `{refuse:true, reason}` marker (the caller renders a VISIBLE refusal) OR a
+ * ready-to-issue `{url, method, headers, body}`:
+ * - a missing or SAFE method refuses (`reason:"safe-method"`) - HIGH-7;
+ * - a blank/absent/whitespace token refuses (`reason:"missing-token"`) - HIGH-1 fail-closed;
+ * - otherwise the body is JSON and the headers carry `Content-Type: application/json` (so the write passes
+ * `LoopbackBoundary.isJson`) plus the CSRF token under `headerName` (defaulting to DEFAULT_CSRF_HEADER).
+ *
+ * The optional `extra` object is merged into the JSON body - the modal submit path (TODO-416) uses it to carry
+ * the server-minted `idempotencyKey` and the `targetId`, so a double-click/re-submit/browser-retry all carry the
+ * same key and the server can check the key's `(action, targetId)` binding. A bare submit (no `extra`) sends
+ * exactly `{action}` as before.
+ */
+ function buildActionRequest(action, token, headerName, extra) {
+ if (!action || isSafeMethod(action.method) || !action.method)
+ return { refuse: true, reason: "safe-method" };
+ if (isBlankToken(token))
+ return { refuse: true, reason: "missing-token" };
+ const headers = { "Content-Type": "application/json" };
+ headers[headerName || DEFAULT_CSRF_HEADER] = token;
+ const payload = { action: action.id };
+ if (extra) for (const k in extra) if (Object.hasOwn(extra, k) && extra[k] != null) payload[k] = extra[k];
+ return {
+ url: action.endpoint,
+ method: action.method,
+ headers: headers,
+ body: JSON.stringify(payload)
+ };
+ }
+
+ /** Maps a buildActionRequest refusal reason to the visible message shown in the row-action refusal banner. */
+ function actionRefusalMessage(reason) {
+ if (reason === "safe-method")
+ return "action must use a non-safe method (POST/PUT/PATCH/DELETE)";
+ if (reason === "missing-token")
+ return "no CSRF token available - the page did not supply one, so the request was not sent";
+ if (reason === "request-failed")
+ return "the request could not be completed";
+ return "the action was refused";
+ }
+
+ /**
+ * The frozen set of typed action-result outcome tokens (ActionResult.Outcome) - the four synchronous outcomes
+ * plus the two async terminal states reserved for TODO-425 (`cancelled`, `cancelled-after-effect`). An outcome
+ * token not in this set is normalized to `unknown` (a visible, non-optimistic state - never an optimistic
+ * success).
+ */
+ const ACTION_OUTCOMES = {
+ "success": 1, "failure": 1, "refusal": 1, "unknown": 1, "cancelled": 1, "cancelled-after-effect": 1
+ };
+
+ /** JSON.parse guarded against a null/blank/malformed body - returns the parsed value or null, never throws. */
+ function parseJsonSafe(text) {
+ if (text == null || text === "") return null;
+ try { return JSON.parse(text); } catch (e) { return null; }
+ }
+
+ /**
+ * Parses a 2xx action-submit body into a typed ActionResult, or null when the body is absent/malformed or does
+ * NOT carry the contract's load-bearing `outcome` discriminator. A null return means "no typed result was
+ * carried" - the caller treats that as a bare success (the pre-416 behavior) rather than inventing an outcome.
+ */
+ function parseActionResult(text) {
+ const o = parseJsonSafe(text);
+ if (!o || typeof o !== "object" || o.outcome == null) return null;
+ return o;
+ }
+
+ /**
+ * Normalizes a typed ActionResult's outcome to a known token, mapping anything unrecognized to `unknown` - so an
+ * unknown/garbled outcome renders as a visible non-optimistic state, never as an optimistic success.
+ */
+ function normalizeOutcome(result) {
+ const o = result && result.outcome;
+ return (o != null && Object.hasOwn(ACTION_OUTCOMES, o)) ? o : "unknown";
+ }
+
+ /**
+ * Parses a 2xx action-submit body into an ASYNC "job accepted" pointer (AsyncJobRef), or null when it is not
+ * one. Whether an action is asynchronous is a property of the RESPONSE, not the declared RowAction (TODO-425):
+ * the same POST returns EITHER a terminal ActionResult (has `outcome`) OR this pointer (has `streamUrl`), so no
+ * new RowAction wire field is needed and the two shapes are disjoint. A job pointer MUST carry a non-blank
+ * `streamUrl` (the SSE capability URL); a body without one is not a job pointer and falls through to the normal
+ * typed-result path.
+ */
+ function parseJobStarted(text) {
+ const o = parseJsonSafe(text);
+ if (!o || typeof o !== "object") return null;
+ if (isBlankToken(o.streamUrl)) return null;
+ return o;
+ }
+
+ /**
+ * Builds the fail-closed cancel-request descriptor for an async job (a non-safe POST to the job's cancelUrl) -
+ * pure, DOM/fetch-free, mirroring buildActionRequest's fail-closed token contract. Returns EITHER a
+ * `{refuse:true, reason}` marker OR a ready `{url, method:"POST", headers, body}`; the body is an empty JSON
+ * object so the write passes LoopbackBoundary.isJson, and the CSRF token rides `headerName`. Server-side
+ * cancellation is authoritative (a client cannot be trusted to stop the work); this only asks for it.
+ */
+ function buildJobCancelRequest(cancelUrl, token, headerName) {
+ if (isBlankToken(cancelUrl))
+ return { refuse: true, reason: "no-cancel-url" };
+ if (isBlankToken(token))
+ return { refuse: true, reason: "missing-token" };
+ const headers = { "Content-Type": "application/json" };
+ headers[headerName || DEFAULT_CSRF_HEADER] = token;
+ return { url: cancelUrl, method: "POST", headers: headers, body: "{}" };
+ }
+
+ /**
+ * Builds a visible, non-optimistic transport-refusal classification for ANY non-2xx action response (HIGH-3),
+ * WITHOUT requiring the typed action-result schema. Prefers the boundary's `X-Loopback-Boundary` reason header,
+ * then the small `{reason,message}` JSON envelope the LoopbackBoundaryFilter emits, then a status-derived
+ * fallback - so a 403/415/421 (or a missing/malformed body) always maps to a comprehensible named refusal rather
+ * than a generic/empty/silent failure on a security refusal.
+ */
+ function transportRefusal(status, boundaryReason, envelope) {
+ const code = ! isBlankToken(boundaryReason) ? boundaryReason
+ : (envelope && envelope.reason ? envelope.reason : ("http:" + (status || 0)));
+ let message = envelope && envelope.message ? envelope.message : null;
+ if (isBlankToken(message)) message = transportStatusMessage(status);
+ return { code: code, message: message };
+ }
+
+ /** A comprehensible fallback message for the transport statuses the LoopbackBoundary answers with. */
+ function transportStatusMessage(status) {
+ if (status === 403) return "the request was refused by the server boundary (403)";
+ if (status === 415) return "the request body was not accepted as JSON (415)";
+ if (status === 421) return "the request was misdirected - Host did not match (421)";
+ return "the request was refused (" + (status == null ? "no status" : status) + ")";
+ }
+
+ /**
+ * The visible text for a settled action outcome - a pure (data-in/text-out) mapping over every outcome the typed
+ * result can carry AND the transport-refusal case, so no terminal state is silent. `cls` is
+ * `{outcome, refusalCode, message, replay, transport}`.
+ */
+ function actionOutcomeMessage(cls) {
+ const replay = cls.replay ? " (replay of a previous attempt)" : "";
+ const detail = cls.message ? ": " + cls.message : "";
+ switch (cls.outcome) {
+ case "success": return "Done" + detail + replay + ".";
+ case "failure": return "Failed" + detail + ".";
+ case "refusal": return (cls.transport ? "Request refused" : "Refused") + " (" + (cls.refusalCode || "unknown") + ")" + detail + ".";
+ case "cancelled": return "Cancelled" + detail + ".";
+ case "cancelled-after-effect": return "Cancelled after a partial effect" + detail + ".";
+ default: return "Outcome unknown - the write may or may not have completed" + detail + ".";
+ }
+ }
+
+ /** Whether an action is presented as a modal dialog (`present=dialog`) - the TODO-416 modal/form path. */
+ function isDialogAction(action) {
+ return !! (action && action.present === "dialog");
+ }
+
/**
* The minimum honored polling interval, in milliseconds - mirrors {@code ViewDef.MIN_POLL_INTERVAL_MS}. The
* server is the authoritative clamp (a `pollIntervalMs` value already arriving in VIEW_META has already been
@@ -288,6 +539,22 @@
rowEl.className += (rowEl.className ? " " : "") + "juneau-view-detail-row";
}
};
+
+ // A declared rowActions list appends ONE synthetic, non-orderable/non-searchable trailing column whose
+ // cell is the per-row action trigger. initTable(...) appends the matching <th> to <thead> before booting
+ // DataTables so column and header counts stay in step. The action BEHAVIOR (modal/form/typed result) is a
+ // later wave (TODO-416); this column only surfaces the menu trigger and routes its click to submitRowAction.
+ if (viewDef.rowActions && viewDef.rowActions.length) {
+ opts.columns.push({
+ data: null,
+ orderable: false,
+ searchable: false,
+ className: "juneau-view-actions-cell",
+ defaultContent: "",
+ title: "",
+ render: function () { return actionTriggerMarkup(); }
+ });
+ }
return opts;
}
@@ -580,6 +847,11 @@
* user's action and then redo it once the write's own result repaints it (design doc §9.1 B5) - so a poll
* tick skips its ENTIRE redraw whenever ANY row in this table carries the marker, leaving the table exactly
* as-is (stale, but honestly so) until that write settles.
+ *
+ * <p>Scoped to SYNCHRONOUS writes ONLY: it reads `data-juneau-inflight`, NOT the async job marker
+ * (`data-juneau-job`, set by setRowJobRunning). A long-running TODO-425 job must NOT freeze the whole table's
+ * polling for up to the 120s hard timeout - so it uses the distinct marker this function deliberately ignores
+ * (HIGH-9).
*/
function hasInFlightRow(table) {
return !!table.querySelector("tbody tr[data-juneau-inflight]");
@@ -684,6 +956,739 @@
render();
}
+ /** The per-row action-menu trigger markup (returned by the synthetic actions column's render). */
+ function actionTriggerMarkup() {
+ const icons = window.JuneauViews && window.JuneauViews.icons;
+ const glyph = icons?.resolveIcon ? icons.resolveIcon("more_vert") : null;
+ const inner = glyph != null ? glyph : "\u22EF"; // horizontal ellipsis fallback when no glyph is registered
+ return '<button type="button" class="juneau-view-action-trigger" aria-haspopup="menu" ' +
+ 'aria-label="Row actions">' + inner + '</button>';
+ }
+
+ /**
+ * Reads the auto-embedded CSRF token off the view table's `data-juneau-csrf` attribute (stamped by
+ * ViewTable from the LoopbackBoundaryFilter request attribute, or set by the host as an override/fallback).
+ * Returns the raw attribute value (possibly null/blank); isBlankToken(...) is what decides fail-closed, so a
+ * whitespace value is NOT normalized away here - it must reach the same blank test the server boundary uses.
+ */
+ function resolveCsrfToken(table) {
+ return table.getAttribute("data-juneau-csrf");
+ }
+
+ /** The per-table CSRF header-name override (`data-juneau-csrf-header`), else the framework default. */
+ function resolveCsrfHeaderName(table) {
+ const override = table.getAttribute("data-juneau-csrf-header");
+ return isBlankToken(override) ? DEFAULT_CSRF_HEADER : override.trim();
+ }
+
+ /** Appends the actions column's header cell to <thead> (kept in step with buildOptions' synthetic column). */
+ function appendActionsHeaderCell(table) {
+ const headRow = table.querySelector("thead tr");
+ if (!headRow) return;
+ const th = document.createElement("th");
+ th.className = "juneau-view-actions-th";
+ th.setAttribute("aria-label", "Actions");
+ headRow.appendChild(th);
+ }
+
+ // ==================================================================================================================
+ // ROW SELECTION + BULK MUTATION (TODO-428) - two INDEPENDENT opt-ins (HIGH-5), detected purely from DOM
+ // attributes ViewTable stamps (SELECT_ATTR / BULK_ATTR) - NEVER from VIEW_META/viewDef. Enabling selection
+ // alone can never surface a bulk-mutate control: hasBulk(...) is only ever consulted from within the
+ // `hasSelection(table)` branch of initTable, and BULK_ATTR is only ever stamped by ViewTable when a
+ // BulkMutateDef (which itself REQUIRES a WritePermit + a SelectionDef to construct) was supplied.
+ // ==================================================================================================================
+
+ /** Whether `table` declares row selection (SelectionDef was supplied to ViewTable.of(...)). */
+ function hasSelection(table) {
+ return table.getAttribute(SELECT_ATTR) === "1";
+ }
+
+ /** Whether `table` declares bulk mutation (BulkMutateDef was supplied to ViewTable.of(...)). */
+ function hasBulk(table) {
+ return table.getAttribute(BULK_ATTR) === "1";
+ }
+
+ /**
+ * Stamps the STABLE row id (MED-11) onto a just-created `<tr>`, read from that row's OWN data via the
+ * `rowIdField` key - never a DOM/table index. A no-op when `rowIdField` is absent (selection not declared) or
+ * the row has no such key (nothing to stamp; that row is simply unselectable). This is the ONLY place
+ * `ROW_ID_ATTR` is written; every reader elsewhere (selection wiring, bulk execution, the TODO-416 modal path's
+ * `submitActionDialog`) treats it as already-authoritative once stamped.
+ */
+ function stampRowId(rowEl, rowData, rowIdField) {
+ if (!rowIdField) return;
+ const id = rowIdOf(rowData, rowIdField);
+ if (id != null) rowEl.setAttribute(ROW_ID_ATTR, String(id));
+ }
+
+ /** The selection checkbox cell's markup - a bare, unlabeled-by-design checkbox (the row IS its own label). */
+ function selectionCellMarkup(checked) {
+ return '<input type="checkbox" class="juneau-view-select-checkbox" aria-label="Select row"' +
+ (checked ? " checked" : "") + '>';
+ }
+
+ /**
+ * Builds the synthetic, non-orderable/non-searchable LEADING selection column (mirrors how a declared
+ * rowActions list appends a synthetic TRAILING column in buildOptions) - `selectionState.selected` is a
+ * live `Set` of currently-selected stable row ids, so a redraw always paints each row's checkbox from the
+ * CURRENT selection, never a stale snapshot.
+ */
+ function buildSelectionColumnDef(selectionState) {
+ return {
+ data: null,
+ orderable: false,
+ searchable: false,
+ className: "juneau-view-select-cell",
+ defaultContent: "",
+ title: "",
+ render: function (data, type, rowData) {
+ if (type && type !== "display") return "";
+ const id = rowIdOf(rowData, selectionState.rowIdField);
+ return selectionCellMarkup(id != null && selectionState.selected.has(String(id)));
+ }
+ };
+ }
+
+ /**
+ * Wires row selection: per-row checkbox toggle (delegated `change` listener - checkboxes are re-created on
+ * every draw, the table element is not), the optional select-all header checkbox (only when `SelectionDef`
+ * declared `selectAll`), and the off-screen-id-drop persistence rule (Q2/MED-11) on every `draw.dt` (sort,
+ * page, or a TODO-426 poll tick). Select-all is scoped to the CURRENT draw's rows only (the ones actually on
+ * screen) - consistent with the drop rule, it can never reach into an off-screen page.
+ */
+ function initSelection(table, dt, selectionState, ctx) {
+ function currentRowIds() {
+ const ids = [];
+ Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+ ids.push(tr.getAttribute(ROW_ID_ATTR));
+ });
+ return ids;
+ }
+
+ function refresh() {
+ if (ctx && ctx.bulkToolbar) ctx.bulkToolbar.refresh(selectionState.selected.size);
+ }
+
+ table.addEventListener("change", function (e) {
+ const cb = e.target && e.target.closest ? e.target.closest(".juneau-view-select-checkbox") : null;
+ if (!cb) return;
+ const tr = cb.closest("tr");
+ const id = tr ? tr.getAttribute(ROW_ID_ATTR) : null;
+ if (id == null) return;
+ if (cb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
+ refresh();
+ });
+
+ if (table.getAttribute(SELECT_ALL_ATTR) === "1") {
+ const th = table.querySelector(".juneau-view-select-th");
+ if (th) {
+ const allCb = document.createElement("input");
+ allCb.type = "checkbox";
+ allCb.className = "juneau-view-select-all-checkbox";
+ allCb.setAttribute("aria-label", "Select all rows on this page");
+ allCb.addEventListener("change", function () {
+ Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+ const id = tr.getAttribute(ROW_ID_ATTR);
+ const cb = tr.querySelector(".juneau-view-select-checkbox");
+ if (cb) cb.checked = allCb.checked;
+ if (allCb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
+ });
+ refresh();
+ });
+ th.appendChild(allCb);
+ }
+ }
+
+ // The off-screen-id-drop rule (Q2/MED-11): every redraw prunes the live selection down to ids still
+ // actually on screen, so a poll/sort/page tick can never leave a bulk mutate targeting a row the user can
+ // no longer see.
+ dt.on("draw.dt", function () {
+ const pruned = pruneSelection(Array.from(selectionState.selected), currentRowIds());
+ selectionState.selected = new Set(pruned);
+ refresh();
+ });
+ }
+
+ /**
+ * Reads and JSON.parses the bulk-actions sidecar (`BULK_SIDECAR_ID_PREFIX + id`) - the independently-versioned
+ * {@code BulkMutateDef} contract, deliberately never merged into VIEW_META (R2 guard). Returns `null` on a
+ * missing or malformed sidecar (the caller treats that as "no usable bulk config" and withholds the toolbar,
+ * rather than guessing).
+ */
+ function readBulkDef(id) {
+ const sidecar = document.getElementById(BULK_SIDECAR_ID_PREFIX + id);
+ if (!sidecar) return null;
+ return parseJsonSafe(sidecar.textContent);
+ }
+
+ /**
+ * Builds the bulk-actions toolbar: a live "N selected" count plus one button per declared bulk action, each
+ * disabled while the selection is empty (there is nothing for a bulk action to target). A click drives
+ * executeBulkAction(...) - the per-target submit path, never an aggregate one.
+ */
+ function buildBulkToolbar(bulkDef, table, ctx, selectionState) {
+ const bar = document.createElement("div");
+ bar.className = "juneau-view-bulk-toolbar";
+ bar.setAttribute("data-testid", "bulk-toolbar");
+
+ const countEl = document.createElement("span");
+ countEl.className = "juneau-view-bulk-count";
+ bar.appendChild(countEl);
+
+ const buttons = (bulkDef.actions || []).map(function (action) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "juneau-view-bulk-action-btn";
+ btn.textContent = action.label || action.id;
+ btn.disabled = true;
+ btn.addEventListener("click", function () { executeBulkAction(action, table, ctx, selectionState); });
+ bar.appendChild(btn);
+ return btn;
+ });
+
+ return {
+ el: bar,
+ refresh: function (count) {
+ countEl.textContent = count > 0 ? (count + " selected") : "";
+ buttons.forEach(function (b) { b.disabled = count === 0; });
+ }
+ };
+ }
+
+ /**
+ * Executes ONE bulk action over the current selection as N INDEPENDENT per-row writes (HIGH-5) - it is a
+ * plain loop calling the SAME submitRowAction(...) the single-row action-menu uses, once per selected id, each
+ * carrying that row's stable id as `targetId` in the JSON body (the same `extra` convention the TODO-416 modal
+ * submit path already uses for `idempotencyKey`/`targetId`). There is deliberately NO aggregate request and
+ * NO aggregate result: each row gets its own TODO-417 in-flight marker and its own typed ActionResult,
+ * rendered independently, so one target's failure/refusal/unknown can never be hidden behind an overall
+ * "success" - and each clears ITS OWN `data-juneau-inflight` on ITS OWN terminal outcome (MED-4), so a stuck
+ * target can never halt the whole table's polling. A selected id whose row is no longer on screen (e.g. it
+ * left the page between the click and this loop running) is silently skipped - the persistence rule (MED-11)
+ * never lets an off-screen row become an actionable target.
+ */
+ function executeBulkAction(action, table, ctx, selectionState) {
+ const ids = Array.from(selectionState.selected);
+ if (!ids.length) return;
+ const byId = {};
+ Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+ byId[tr.getAttribute(ROW_ID_ATTR)] = tr;
+ });
+ ids.forEach(function (id) {
+ const tr = byId[id];
+ if (!tr) return;
+ submitRowAction(action, table, tr, ctx, { targetId: id });
+ });
+ }
+
+ /** Renders a VISIBLE, non-blocking inline error line (anti-silent-degradation) into `container`. */
+ function renderInlineError(container, message) {
+ const el = document.createElement("div");
+ el.className = "juneau-view-error";
+ el.setAttribute("role", "alert");
+ el.textContent = message;
+ container.appendChild(el);
+ }
+
+ /**
+ * Issues one row action, FAIL-CLOSED. Resolves the table's token + header name, asks the pure
+ * buildActionRequest(...) for a request descriptor, and:
+ * - on a refusal marker (safe method, or blank/absent/whitespace token) renders a VISIBLE refusal and sends
+ * NOTHING - no silent degradation, and never an empty-header request the server would 403;
+ * - otherwise marks the row in-flight (TODO-417) and issues the JSON fetch with the CSRF header, then settles
+ * the row from the typed ActionResult / transport refusal (TODO-416).
+ *
+ * This is the DIRECT submit path (no confirmation dialog); a `present=dialog` action goes through
+ * openActionDialog(...) instead. The optional `extra` payload (idempotencyKey + targetId) is carried on the
+ * dialog path. The client refusal is defense-against-omission; the landed server-side LoopbackBoundary is the
+ * real control.
+ */
+ function submitRowAction(action, table, tr, ctx, extra) {
+ const req = buildActionRequest(action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra);
+ if (req.refuse) {
+ renderRowActionRefusal(tr, action, req.reason);
+ return;
+ }
+ setRowInFlight(tr, true);
+ fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+ .then(function (resp) {
+ settleActionResponse(resp, action, table, tr, ctx);
+ })
+ .catch(function () {
+ // A network-level failure is itself a terminal outcome: clear the marker (so polling resumes) and
+ // render a visible refusal rather than leaving the row stuck in-flight.
+ setRowInFlight(tr, false);
+ renderRowActionRefusal(tr, action, "request-failed");
+ });
+ }
+
+ /**
+ * Whether `table` currently has a row marked in-flight (design doc §9.1 B5) - see hasInFlightRow above; this is
+ * the setter half. Marks the `<tr>` in-flight for an OUTSTANDING SYNCHRONOUS WRITE and disables its action
+ * trigger so a double-click cannot issue a second write (TODO-417). The marker is scoped to synchronous writes
+ * ONLY - a long-running async job (TODO-425) uses a distinct affordance that does not inhibit table polling,
+ * because initPolling skips the WHOLE table's poll while any row carries this marker.
+ */
+ function setRowInFlight(tr, on) {
+ if (!tr) return;
+ if (on) tr.setAttribute("data-juneau-inflight", "1");
+ else tr.removeAttribute("data-juneau-inflight");
+ const trigger = tr.querySelector ? tr.querySelector(".juneau-view-action-trigger") : null;
+ if (trigger) trigger.disabled = !!on;
+ }
+
+ /**
+ * Settles a row from an action-submit response - the TODO-416/417 join point. It ALWAYS clears the in-flight
+ * marker and re-enables the action FIRST (on success, failure, refusal, unknown, AND a transport refusal), so a
+ * stuck marker can never freeze the whole table's polling (MED-4); then it renders the outcome:
+ * - non-2xx -> a visible TRANSPORT refusal built from `X-Loopback-Boundary` + the `{reason,message}` envelope,
+ * WITHOUT requiring the typed action-result schema (HIGH-3);
+ * - 2xx + typed ActionResult -> success (merge/redraw the row from the authoritative payload), failure,
+ * named refusal, or a non-optimistic unknown - every outcome is rendered, none is silent;
+ * - 2xx + no typed body -> a bare success (the pre-416 behavior: redraw for an onSuccess=redraw action).
+ */
+ function settleActionResponse(resp, action, table, tr, ctx) {
+ setRowInFlight(tr, false); // EVERY terminal outcome clears the marker first - polling must always resume.
+ if (! resp) { renderActionOutcome(tr, { outcome: "unknown" }); return; }
+
+ if (! resp.ok) {
+ const boundaryReason = (resp.headers && typeof resp.headers.get === "function")
+ ? resp.headers.get("X-Loopback-Boundary") : null;
+ readBodyText(resp).then(function (text) {
+ const t = transportRefusal(resp.status, boundaryReason, parseJsonSafe(text));
+ renderActionOutcome(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message });
+ });
+ return;
+ }
+
+ readBodyText(resp).then(function (text) {
+ // TODO-425: an ASYNC action's start POST returns a job pointer (not a terminal result). The in-flight
+ // marker was ALREADY cleared above (first line of settle), so table polling has resumed BEFORE the long
+ // job runs - the job then uses the DISTINCT job-running affordance (data-juneau-job), never
+ // data-juneau-inflight, so hasInFlightRow/initPolling never freeze the whole table for the job's
+ // duration (HIGH-9).
+ const started = parseJobStarted(text);
+ if (started) {
+ startJobStream(started, action, table, tr, ctx);
+ return;
+ }
+ const result = parseActionResult(text);
+ if (! result) { // bare 2xx, no typed result -> pre-416 success behavior.
+ applySuccessBehavior(action, table, tr, ctx, null);
+ return;
+ }
+ if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
+ renderActionOutcome(tr, { outcome: "unknown",
+ message: "action-result contract mismatch (page='" + result.contractVersion +
+ "', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" });
+ return;
+ }
+ const outcome = normalizeOutcome(result);
+ if (outcome === "success") {
+ applySuccessBehavior(action, table, tr, ctx, result);
+ renderActionOutcome(tr, { outcome: "success", replay: !! result.replay, message: result.message });
+ } else {
+ renderActionOutcome(tr, {
+ outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
+ });
+ }
+ });
+ }
+
+ /** Reads a fetch Response body as text, defensively (a stubbed/absent body resolves to "" rather than throwing). */
+ function readBodyText(resp) {
+ if (resp && typeof resp.text === "function") {
+ try { return Promise.resolve(resp.text()); } catch (e) { return Promise.resolve(""); }
+ }
+ return Promise.resolve("");
+ }
+
+ /**
+ * Applies a successful action's onSuccess behavior: `mergeRow` re-renders the row from the result's authoritative
+ * payload (TODO-417), `redraw` reloads the table, `navigate` is left to the consumer. A bare success (no typed
+ * result) with onSuccess=redraw still redraws, preserving the pre-416 direct-submit behavior.
+ */
+ function applySuccessBehavior(action, table, tr, ctx, result) {
+ if (action.onSuccess === "mergeRow" && result && result.row != null) {
+ mergeRowFromResult(tr, ctx, result.row);
+ } else if (action.onSuccess === "redraw" && ctx && ctx.redraw) {
+ ctx.redraw();
+ }
+ }
+
+ /**
+ * Re-renders a row from the ActionResult's authoritative row payload (the `MERGE_ROW` case). Prefers
+ * DataTables' native row API when present; otherwise fires an optional `ctx.mergeRow(tr, rowData)` hook. Always
+ * stamps `data-juneau-row-merged` on the row so the re-render is observable (and so a host can style a
+ * just-updated row).
+ */
+ function mergeRowFromResult(tr, ctx, rowData) {
+ let mergedViaDt = false;
+ try {
+ if (ctx && ctx.dataTable && typeof ctx.dataTable.row === "function") {
+ const row = ctx.dataTable.row(tr);
+ if (row && typeof row.data === "function") {
+ row.data(rowData);
+ if (typeof row.draw === "function") row.draw(false);
+ mergedViaDt = true;
+ }
+ }
+ } catch (e) { mergedViaDt = false; }
+ if (! mergedViaDt && ctx && typeof ctx.mergeRow === "function") ctx.mergeRow(tr, rowData);
+ if (tr && tr.setAttribute) tr.setAttribute("data-juneau-row-merged", "1");
+ }
+
+ /**
+ * Renders a VISIBLE settled-outcome banner into the row's actions cell (anti-silent-degradation): success gets a
+ * `role=status`, everything else (failure, refusal, transport refusal, cancelled, unknown) gets a `role=alert`.
+ * Distinct from renderRowActionRefusal (which is the pre-flight fail-closed refusal); this is the post-response
+ * outcome.
+ */
+ function renderActionOutcome(tr, cls) {
+ const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+ let banner = cell.querySelector ? cell.querySelector(".juneau-view-action-outcome") : null;
+ if (! banner) {
+ banner = document.createElement("div");
+ banner.className = "juneau-view-action-outcome";
+ banner.setAttribute("data-testid", "action-outcome");
+ cell.appendChild(banner);
+ }
+ const state = cls.outcome || "unknown";
+ banner.setAttribute("data-state", state);
+ banner.setAttribute("role", state === "success" ? "status" : "alert");
+ banner.textContent = actionOutcomeMessage(cls);
+ }
+
+ // ==================================================================================================================
+ // ASYNC JOBS (TODO-425): a long-running job streamed over SSE, with a DISTINCT running affordance
+ // ==================================================================================================================
+
+ /**
+ * Marks a row as running a LONG async job (TODO-425) - deliberately a DIFFERENT attribute
+ * (`data-juneau-job`) from the synchronous in-flight marker (`data-juneau-inflight`). This is the whole point
+ * of HIGH-9: hasInFlightRow (and therefore initPolling) freezes the ENTIRE table's polling while ANY row
+ * carries `data-juneau-inflight`, which is correct for a short synchronous write but catastrophic for a job
+ * that can run up to the server's 120s hard timeout - it would hide a resurrected incident on a PagerDuty
+ * table for the whole job. A job therefore NEVER sets `data-juneau-inflight`; it sets this distinct marker,
+ * which hasInFlightRow does NOT read, so the table keeps polling for the entire duration of the job. The
+ * action trigger is disabled while running so a second job cannot be started on the same row.
+ */
+ function setRowJobRunning(tr, on) {
+ if (!tr) return;
+ if (on) tr.setAttribute("data-juneau-job", "1");
+ else tr.removeAttribute("data-juneau-job");
+ const trigger = tr.querySelector ? tr.querySelector(".juneau-view-action-trigger") : null;
+ if (trigger) trigger.disabled = !!on;
+ }
+
+ /**
+ * Renders (and updates) the VISIBLE, live job-progress banner in the row's actions cell, plus a Cancel button
+ * wired to the job's cancelUrl. The progress text is painted with `textContent` ONLY (never innerHTML) - the
+ * streamed content is customer-adjacent and this origin holds the CSRF token, so a typed/escaped path is the
+ * only safe one (same invariant as the modal fields, BLK-1/MED-9). Distinct `data-testid`/class from the
+ * settled-outcome banner so a running job and a terminal outcome are never confused.
+ */
+ function renderJobProgress(tr, text, started, table) {
+ const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+ let banner = cell.querySelector ? cell.querySelector(".juneau-view-job-progress") : null;
+ if (! banner) {
+ banner = document.createElement("div");
+ banner.className = "juneau-view-job-progress";
+ banner.setAttribute("data-testid", "job-progress");
+ banner.setAttribute("role", "status");
+ const msg = document.createElement("span");
+ msg.className = "juneau-view-job-progress-msg";
+ banner.appendChild(msg);
+ if (started && ! isBlankToken(started.cancelUrl)) {
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "juneau-view-job-cancel";
+ cancel.textContent = "Cancel";
+ cancel.addEventListener("click", function () { cancelJob(started, table, tr); });
+ banner.appendChild(cancel);
+ }
+ cell.appendChild(banner);
+ }
+ const msgEl = banner.querySelector(".juneau-view-job-progress-msg");
+ if (msgEl) msgEl.textContent = (text == null ? "" : String(text));
+ }
+
+ /** Removes the live job-progress banner from a row (called when the job settles to a terminal outcome). */
+ function clearJobProgress(tr) {
+ const cell = tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr;
+ const banner = cell.querySelector ? cell.querySelector(".juneau-view-job-progress") : null;
+ if (banner && banner.parentNode) banner.parentNode.removeChild(banner);
+ }
+
+ /**
+ * Opens the SSE progress stream for a started async job and drives the row through its lifecycle (TODO-425).
+ * The stream URL ITSELF is the capability (an unguessable >=128-bit token) - a browser EventSource CANNOT set
+ * an X-Csrf-Token header, so unguessability is the access control, not a CSRF header (HIGH-4). `progress`
+ * events update the live banner; the single terminal `result` event carries the typed ActionResult and settles
+ * the row (reusing the same normalize/contract-check/render path as a synchronous result, so cancelled /
+ * cancelled-after-effect render via actionOutcomeMessage without any new UI). A stream error is itself a
+ * non-optimistic terminal outcome. Throughout, the row carries only the DISTINCT `data-juneau-job` marker, so
+ * the table keeps polling (HIGH-9).
+ */
+ function startJobStream(started, action, table, tr, ctx) {
+ if (typeof EventSource === "undefined") {
+ // No SSE transport in this browser - a visible, non-optimistic outcome; polling was never frozen.
+ renderActionOutcome(tr, { outcome: "unknown", message: "live progress is unavailable in this browser" });
+ return null;
+ }
+ setRowJobRunning(tr, true);
+ renderJobProgress(tr, "Working\u2026", started, table);
+ const es = new EventSource(started.streamUrl);
+ const st = { settled: false };
+ function finish(result, fallback) {
+ if (st.settled) return;
+ st.settled = true;
+ es.close();
+ setRowJobRunning(tr, false);
+ clearJobProgress(tr);
+ if (result) finishJobFromResult(action, table, tr, ctx, result);
+ else renderActionOutcome(tr, fallback);
+ }
+ es.addEventListener("progress", function (e) {
+ if (! st.settled) renderJobProgress(tr, e.data, started, table);
+ });
+ es.addEventListener("result", function (e) {
+ finish(parseActionResult(e.data), { outcome: "unknown", message: "the job produced no readable result" });
+ });
+ es.addEventListener("error", function () {
+ finish(null, { outcome: "unknown", message: "the progress stream was interrupted" });
+ });
+ if (ctx) ctx._jobSource = es;
+ return es;
+ }
+
+ /**
+ * Settles a row from an async job's terminal ActionResult (the SSE `result` event) - the async twin of
+ * settleActionResponse's 2xx typed branch, reusing the SAME contract-version handshake, outcome normalization,
+ * success behavior and visible-outcome render. cancelled / cancelled-after-effect are just outcomes here;
+ * actionOutcomeMessage already renders both.
+ */
+ function finishJobFromResult(action, table, tr, ctx, result) {
+ if (! result) { renderActionOutcome(tr, { outcome: "unknown" }); return; }
+ if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
+ renderActionOutcome(tr, { outcome: "unknown",
+ message: "action-result contract mismatch (page='" + result.contractVersion +
+ "', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" });
+ return;
+ }
+ const outcome = normalizeOutcome(result);
+ if (outcome === "success") {
+ applySuccessBehavior(action, table, tr, ctx, result);
+ renderActionOutcome(tr, { outcome: "success", replay: !! result.replay, message: result.message });
+ } else {
+ renderActionOutcome(tr, {
+ outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
+ });
+ }
+ }
+
+ /**
+ * Requests cancellation of a running job (fail-closed CSRF POST to the job's cancelUrl). The SERVER is
+ * authoritative - the terminal cancelled / cancelled-after-effect outcome still arrives over the SSE `result`
+ * event, so this only asks; a blank token or missing cancelUrl renders a visible refusal and sends nothing.
+ */
+ function cancelJob(started, table, tr) {
+ const req = buildJobCancelRequest(started && started.cancelUrl, resolveCsrfToken(table), resolveCsrfHeaderName(table));
+ if (req.refuse) {
+ renderRowActionRefusal(tr, { id: "cancel", label: "Cancel" }, req.reason === "missing-token" ? "missing-token" : "request-failed");
+ return;
+ }
+ fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+ .catch(function () { renderRowActionRefusal(tr, { id: "cancel", label: "Cancel" }, "request-failed"); });
+ }
+
+ /**
+ * Opens a `present=dialog` action's modal overlay (TODO-416). When the action declares a form-source URL, the
+ * modal-open confirmation is a READ-ONLY GET that returns the typed ModalDef JSON (confirmation fields + the
+ * server-minted idempotency key) - it never mutates (HIGH-7); its typed fields are painted with `textContent`
+ * (never `innerHTML`, never raw markup - BLK-1/MED-9). With no form URL the dialog is a confirm-only prompt
+ * from the declared `confirm` text. The mutation is the SEPARATE non-safe submit the confirm button issues.
+ */
+ function openActionDialog(action, table, tr, ctx) {
+ if (isBlankToken(action.form)) {
+ showActionDialog({ title: action.confirm || action.label || action.id }, action, table, tr, ctx);
+ return;
+ }
+ fetch(action.form, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json" } })
+ .then(function (resp) {
+ if (! resp || ! resp.ok) {
+ // A non-2xx on the read-only confirmation fetch is itself a visible transport refusal - the
+ // modal never opens optimistically on a boundary rejection.
+ const boundaryReason = (resp && resp.headers && typeof resp.headers.get === "function")
+ ? resp.headers.get("X-Loopback-Boundary") : null;
+ return readBodyText(resp).then(function (text) {
+ const t = transportRefusal(resp ? resp.status : 0, boundaryReason, parseJsonSafe(text));
+ renderActionOutcome(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message });
+ });
+ }
+ return readBodyText(resp).then(function (text) {
+ showActionDialog(parseJsonSafe(text) || { title: action.confirm || action.label || action.id },
+ action, table, tr, ctx);
+ });
+ })
+ .catch(function () { renderRowActionRefusal(tr, action, "request-failed"); });
+ }
+
+ /**
+ * Builds a `present=dialog` overlay DOM node (a backdrop + a `role=dialog` box) from a ModalDef, painting the
+ * title and each typed confirmation field with `textContent` ONLY - never `innerHTML`, never raw markup, never a
+ * live-data HTML blob (BLK-1/MED-9): live/remote data is attacker-influenceable and this origin holds the CSRF
+ * token, so a typed/escaped path is the only safe one here. Returns the pieces so the caller can wire buttons.
+ */
+ function buildDialogOverlay(modal, action) {
+ const backdrop = document.createElement("div");
+ backdrop.className = "juneau-view-dialog-backdrop";
+ backdrop.setAttribute("data-testid", "dialog-backdrop");
+
+ const dialog = document.createElement("div");
+ dialog.className = "juneau-view-dialog";
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+
+ const title = document.createElement("h2");
+ title.className = "juneau-view-dialog-title";
+ title.textContent = (modal && modal.title) || (action && (action.confirm || action.label || action.id)) || "Confirm";
+ dialog.appendChild(title);
+
+ if (modal && modal.fields && modal.fields.length) {
+ const dl = document.createElement("dl");
+ dl.className = "juneau-view-dialog-fields";
+ dl.setAttribute("data-testid", "dialog-fields");
+ modal.fields.forEach(function (f) {
+ const dt = document.createElement("dt");
+ dt.textContent = (f && f.label != null) ? String(f.label) : "";
+ const dd = document.createElement("dd");
+ dd.textContent = (f && f.value != null) ? String(f.value) : "";
+ dl.appendChild(dt);
+ dl.appendChild(dd);
+ });
+ dialog.appendChild(dl);
+ }
+
+ const actions = document.createElement("div");
+ actions.className = "juneau-view-dialog-actions";
+ const cancelBtn = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "juneau-view-dialog-cancel";
+ cancelBtn.textContent = "Cancel";
+ const confirmBtn = document.createElement("button");
+ confirmBtn.type = "button";
+ confirmBtn.className = "juneau-view-dialog-confirm";
+ confirmBtn.textContent = (action && action.label) || "Confirm";
+ actions.appendChild(cancelBtn);
+ actions.appendChild(confirmBtn);
+ dialog.appendChild(actions);
+
+ backdrop.appendChild(dialog);
+ return { backdrop: backdrop, dialog: dialog, confirmBtn: confirmBtn, cancelBtn: cancelBtn };
+ }
+
+ /** Shows a dialog overlay for an action and wires its confirm (submit) / cancel (dismiss) buttons. */
+ function showActionDialog(modal, action, table, tr, ctx) {
+ const ui = buildDialogOverlay(modal, action);
+ function close() { if (ui.backdrop.parentNode) ui.backdrop.parentNode.removeChild(ui.backdrop); }
+ ui.cancelBtn.addEventListener("click", close);
+ ui.confirmBtn.addEventListener("click", function () {
+ close();
+ submitActionDialog(modal, action, table, tr, ctx);
+ });
+ document.body.appendChild(ui.backdrop);
+ return ui;
+ }
+
+ /**
+ * Issues the dialog's non-safe submit, carrying the server-minted idempotency key and the row's targetId so the
+ * server can check the key's `(action, targetId)` binding (HIGH-8) - a double-click / re-submit / browser retry
+ * therefore all carry the SAME key. Delegates the fail-closed CSRF submit + in-flight marker + typed-result
+ * settling to submitRowAction(...).
+ */
+ function submitActionDialog(modal, action, table, tr, ctx) {
+ const extra = {};
+ const targetId = (tr && tr.getAttribute) ? tr.getAttribute("data-juneau-row-id") : null;
+ if (targetId != null) extra.targetId = targetId;
+ if (modal && modal.idempotencyKey != null) extra.idempotencyKey = modal.idempotencyKey;
+ submitRowAction(action, table, tr, ctx, extra);
+ }
+
+ /** Renders a VISIBLE row-action refusal (anti-silent-degradation) into the row's actions cell. */
+ function renderRowActionRefusal(tr, action, reason) {
+ const cell = tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr;
+ let banner = cell.querySelector ? cell.querySelector(".juneau-view-action-refusal") : null;
+ if (!banner) {
+ banner = document.createElement("div");
+ banner.className = "juneau-view-action-refusal";
+ banner.setAttribute("role", "alert");
+ banner.setAttribute("data-testid", "action-refusal");
+ cell.appendChild(banner);
+ }
+ const name = (action && (action.label || action.id)) || "action";
+ banner.textContent = "Action '" + name + "' not sent: " + actionRefusalMessage(reason) + ".";
+ }
+
+ /**
+ * Builds the row-action menu element (`<ul role="menu">`) from a view's rowActions. Each item's activation
+ * routes to submitRowAction(...); a safe-method or token-less action still renders (so the menu is honest
+ * about what is declared) but visibly refuses on activation rather than silently doing nothing.
+ */
+ function buildRowActionMenu(viewDef, table, tr, ctx) {
+ const menu = document.createElement("ul");
+ menu.className = "juneau-view-action-menu";
+ menu.setAttribute("role", "menu");
+ menu.setAttribute("data-testid", "action-menu");
+ (viewDef.rowActions || []).forEach(function (action) {
+ const li = document.createElement("li");
+ li.setAttribute("role", "none");
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "juneau-view-action-item";
+ item.setAttribute("role", "menuitem");
+ item.setAttribute("data-action-id", action.id);
+ item.textContent = action.label || action.id;
+ item.addEventListener("click", function () {
+ closeRowActionMenus(table);
+ // A present=dialog action opens the modal (confirmation + optional form) before its submit;
+ // everything else is the direct fail-closed submit.
+ if (isDialogAction(action)) openActionDialog(action, table, tr, ctx);
+ else submitRowAction(action, table, tr, ctx);
+ });
+ li.appendChild(item);
+ menu.appendChild(li);
+ });
+ return menu;
+ }
+
+ /** Removes any open row-action menu under `table` (single-open invariant). */
+ function closeRowActionMenus(table) {
+ Array.prototype.forEach.call(table.querySelectorAll(".juneau-view-action-menu"), function (m) {
+ if (m.parentNode) m.parentNode.removeChild(m);
+ });
+ }
+
+ /**
+ * Wires the row-action menu via ONE delegated click listener on `table` (rather than one per row): a click on
+ * a row's `.juneau-view-action-trigger` toggles a menu of that view's rowActions for that row. Mirrors the
+ * details-expander's delegated-listener pattern above.
+ */
+ function initRowActions(table, dt, viewDef, ctx) {
+ table.addEventListener("click", function (e) {
+ const trigger = e.target && e.target.closest ? e.target.closest(".juneau-view-action-trigger") : null;
+ if (!trigger) return;
+ const tr = trigger.closest("tr");
+ if (!tr) return;
+ const existing = tr.querySelector(".juneau-view-action-menu");
+ closeRowActionMenus(table);
+ if (existing) return; // second click on an open menu's trigger closes it
+ const cell = trigger.closest(".juneau-view-actions-cell") || tr.lastElementChild;
+ if (cell) cell.appendChild(buildRowActionMenu(viewDef, table, tr, ctx));
+ });
+ }
+
// NOSONAR javascript:S3776 -- sequential wiring of one view table's DataTables instance, ribbon, paging pill,
// column search, details expander, and polling; several of these steps and their exact call order are pinned
// verbatim by the wiring canary tests below `functionBody(body, "function initTable(")`, so splitting them
@@ -730,6 +1735,29 @@
};
const opts = buildOptions(viewDef, deps);
+
+ // TODO-428: row selection is detected PURELY from the DOM attribute ViewTable stamped (SELECT_ATTR) -
+ // never from viewDef/VIEW_META (R2 guard). When present, a synthetic LEADING selection column is
+ // unshifted (mirroring how a declared rowActions list appends a synthetic TRAILING one below) and every
+ // created row is additionally stamped with its stable id.
+ const selectionState = hasSelection(table)
+ ? { selected: new Set(), rowIdField: table.getAttribute(ROW_ID_FIELD_ATTR) }
+ : null;
+ if (selectionState) {
+ opts.columns.unshift(buildSelectionColumnDef(selectionState));
+ const priorCreatedRow = opts.createdRow;
+ opts.createdRow = function (rowEl, rowData, index) {
+ if (priorCreatedRow) priorCreatedRow(rowEl, rowData, index);
+ stampRowId(rowEl, rowData, selectionState.rowIdField);
+ };
+ }
+
+ // A declared rowActions list added a synthetic trailing column in buildOptions; append its matching <th>
+ // BEFORE booting DataTables so header and column counts agree (DataTables errors on a mismatch).
+ if (viewDef.rowActions && viewDef.rowActions.length) {
+ appendActionsHeaderCell(table);
+ }
+
const dt = $(table).DataTable(opts);
// A declared `details` field list makes every row expandable.
@@ -751,6 +1779,35 @@
redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
};
+ // A declared rowActions list wires the per-row action menu + fail-closed CSRF submit (delegated listener).
+ if (viewDef.rowActions && viewDef.rowActions.length) {
+ initRowActions(table, dt, viewDef, ctx);
+ }
+
+ // TODO-428: selection wiring (checkbox toggle, select-all, off-screen-id-drop on every draw.dt) is wired
+ // whenever selection was declared - completely independent of whether bulk mutation was ALSO declared.
+ // The bulk toolbar itself (a SEPARATE, independent opt-in - hasBulk(...)) is only ever built INSIDE this
+ // same `if (selectionState)` branch, so a selection-only table (e.g. selectable-for-export) has no code
+ // path that can reach it (HIGH-5's compile/DOM-shape separability guarantee).
+ if (selectionState) {
+ initSelection(table, dt, selectionState, ctx);
+ if (hasBulk(table)) {
+ const bulkDef = readBulkDef(id);
+ if (!bulkDef) {
+ ctx._bulkError = "Juneau view '" + id + "': missing or malformed bulk-actions sidecar; bulk mutation withheld.";
+ error(ctx._bulkError);
+ } else if (bulkDef.contractVersion !== JUNEAU_BULK_CONTRACT_VERSION) {
+ ctx._bulkError = "Juneau view '" + id + "': bulk-actions contract version mismatch (page='" +
+ bulkDef.contractVersion + "', runtime='" + JUNEAU_BULK_CONTRACT_VERSION + "'); bulk mutation withheld.";
+ error(ctx._bulkError);
+ } else {
+ // Selection remains fully usable (e.g. for export) even when bulk mutation is withheld above -
+ // only the toolbar this branch builds is gated on a healthy sidecar.
+ ctx.bulkToolbar = buildBulkToolbar(bulkDef, table, ctx, selectionState);
+ }
+ }
+ }
+
const bar = NS.ribbon?.build ? NS.ribbon.build(viewDef, ctx) : null;
const columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
@@ -769,6 +1826,18 @@
const wrapper = table.parentNode;
const toolbarRow = wrapper ? buildToolbarRow(wrapper, pill, bar) : null;
+ // The bulk toolbar (or its withheld-sidecar error) is inserted into the LEFT cluster, after the paging
+ // pill, as a follow-up DOM step - mirroring the staleness-indicator insertion below, which is likewise a
+ // post-hoc addition rather than a buildToolbarRow(...) parameter (keeping that function's own
+ // signature/tests untouched).
+ if (toolbarRow) {
+ const leftCluster = toolbarRow.querySelector(".juneau-view-toolbar-left");
+ if (leftCluster) {
+ if (ctx.bulkToolbar) leftCluster.appendChild(ctx.bulkToolbar.el);
+ else if (ctx._bulkError) renderInlineError(leftCluster, ctx._bulkError);
+ }
+ }
+
// A declared pollIntervalMs gets its own per-table staleness chip, inserted at the front of the RIGHT
// toolbar cluster (ahead of search/ribbon) without touching buildToolbarRow's own signature/tests.
if (viewDef.pollIntervalMs && toolbarRow) {
@@ -830,7 +1899,65 @@
// Row-details expander - exposed for manual verification.
buildDetailFields: buildDetailFields,
buildDetailPanel: buildDetailPanel,
- initDetailsExpander: initDetailsExpander
+ initDetailsExpander: initDetailsExpander,
+ // Row actions + fail-closed CSRF submit - exposed for manual verification and the fail-closed canary.
+ DEFAULT_CSRF_HEADER: DEFAULT_CSRF_HEADER,
+ isSafeMethod: isSafeMethod,
+ isBlankToken: isBlankToken,
+ buildActionRequest: buildActionRequest,
+ actionRefusalMessage: actionRefusalMessage,
+ resolveCsrfToken: resolveCsrfToken,
+ resolveCsrfHeaderName: resolveCsrfHeaderName,
+ buildRowActionMenu: buildRowActionMenu,
+ submitRowAction: submitRowAction,
+ initRowActions: initRowActions,
+ // Declarative modal + typed action-result + in-flight lifecycle (TODO-416/417) - exposed for the canary
+ // and manual verification.
+ ACTION_RESULT_CONTRACT_VERSION: JUNEAU_ACTION_RESULT_CONTRACT_VERSION,
+ parseActionResult: parseActionResult,
+ normalizeOutcome: normalizeOutcome,
+ transportRefusal: transportRefusal,
+ actionOutcomeMessage: actionOutcomeMessage,
+ isDialogAction: isDialogAction,
+ setRowInFlight: setRowInFlight,
+ settleActionResponse: settleActionResponse,
+ mergeRowFromResult: mergeRowFromResult,
+ renderActionOutcome: renderActionOutcome,
+ openActionDialog: openActionDialog,
+ buildDialogOverlay: buildDialogOverlay,
+ showActionDialog: showActionDialog,
+ submitActionDialog: submitActionDialog,
+ // Async jobs + SSE streaming (TODO-425) - exposed for the canary and manual verification. The job-running
+ // affordance (setRowJobRunning) is DISTINCT from the synchronous in-flight marker: it never freezes polling.
+ parseJobStarted: parseJobStarted,
+ buildJobCancelRequest: buildJobCancelRequest,
+ setRowJobRunning: setRowJobRunning,
+ renderJobProgress: renderJobProgress,
+ clearJobProgress: clearJobProgress,
+ startJobStream: startJobStream,
+ finishJobFromResult: finishJobFromResult,
+ cancelJob: cancelJob,
+ // Row selection + bulk mutation (TODO-428) - two independent opt-ins - exposed for the canary and manual
+ // verification. BULK_CONTRACT_VERSION is exposed above (NS.BULK_CONTRACT_VERSION) alongside the other two
+ // independently-versioned contracts.
+ SELECT_ATTR: SELECT_ATTR,
+ ROW_ID_ATTR: ROW_ID_ATTR,
+ ROW_ID_FIELD_ATTR: ROW_ID_FIELD_ATTR,
+ SELECT_ALL_ATTR: SELECT_ALL_ATTR,
+ BULK_ATTR: BULK_ATTR,
+ BULK_SIDECAR_ID_PREFIX: BULK_SIDECAR_ID_PREFIX,
+ rowIdOf: rowIdOf,
+ pruneSelection: pruneSelection,
+ hasSelection: hasSelection,
+ hasBulk: hasBulk,
+ stampRowId: stampRowId,
+ selectionCellMarkup: selectionCellMarkup,
+ buildSelectionColumnDef: buildSelectionColumnDef,
+ initSelection: initSelection,
+ readBulkDef: readBulkDef,
+ buildBulkToolbar: buildBulkToolbar,
+ executeBulkAction: executeBulkAction,
+ renderInlineError: renderInlineError
};
if (document.readyState === "loading") {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
new file mode 100644
index 0000000000..97d58f4875
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Golden-fixture contract test for the typed {@code ActionResult} wire format (design doc §6.1/§6.2; the write-path
+ * half of {@code TODO-416}).
+ *
+ * <p>
+ * This pins the third independently-versioned wire contract of the toolkit (alongside {@code VIEW_META} and
+ * {@code PAGE_META}): its own {@link ActionResult#CONTRACT_VERSION}, its frozen top-level key order, the reserved
+ * async outcome set, and the omit-when-unset rule for every optional field. Serialization uses the same canonical
+ * compact JSON marshaller ({@link Json#of(Object)}) the runtime reads.
+ */
+class ActionResult_Contract_Test extends TestBase {
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The frozen ActionResult contract
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_success_serializesToFrozenShape() {
+ var row = new LinkedHashMap<String,Object>();
+ row.put("id", "INC-1");
+ row.put("status", "acknowledged");
+ var json = Json.of(ActionResult.success(row));
+
+ var expected = Json.to("{\"contractVersion\":\"1\",\"outcome\":\"success\",\"row\":{\"id\":\"INC-1\",\"status\":\"acknowledged\"}}", Map.class);
+ var actual = Json.to(json, Map.class);
+ assertEquals(expected, actual, json);
+ }
+
+ @Test void a02_contractVersion_isItsOwnValue_notAliasedToViewDef() {
+ // The whole point of MED-7: this is a SEPARATE contract from VIEW_META, so its version must not track ViewDef's.
+ assertEquals("1", ActionResult.CONTRACT_VERSION);
+ assertNotEquals(ViewDef.CONTRACT_VERSION, ActionResult.CONTRACT_VERSION,
+ "ActionResult.CONTRACT_VERSION must be its own value, never aliased to ViewDef.CONTRACT_VERSION");
+ }
+
+ @Test void a03_topLevelKeyOrder_success() {
+ Map<?,?> actual = Json.to(Json.of(ActionResult.success(Map.of("id", "x"))), Map.class);
+ assertEquals(List.of("contractVersion", "outcome", "row"), new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void a04_topLevelKeyOrder_fullResult_matchesBeanTypeOrder() {
+ // contractVersion,outcome,replay,refusalCode,message,row (the @BeanType-pinned order).
+ var r = ActionResult.success(Map.of("id", "x")).replay(true).message("done");
+ Map<?,?> actual = Json.to(Json.of(r), Map.class);
+ assertEquals(List.of("contractVersion", "outcome", "replay", "message", "row"), new ArrayList<>(actual.keySet()));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Outcome discriminator + reserved async terminal states (MED-8)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_syncOutcomeWireTokens() {
+ assertEquals("success", ActionResult.Outcome.SUCCESS.wire());
+ assertEquals("failure", ActionResult.Outcome.FAILURE.wire());
+ assertEquals("refusal", ActionResult.Outcome.REFUSAL.wire());
+ assertEquals("unknown", ActionResult.Outcome.UNKNOWN.wire());
+ }
+
+ @Test void b02_reservedAsyncOutcomes_areFrozenNow() {
+ // Reserved from day one so TODO-425 (async) cannot force a second result-contract bump.
+ assertEquals("cancelled", ActionResult.Outcome.CANCELLED.wire());
+ assertEquals("cancelled-after-effect", ActionResult.Outcome.CANCELLED_AFTER_EFFECT.wire());
+ assertEquals(
+ List.of("SUCCESS", "FAILURE", "REFUSAL", "UNKNOWN", "CANCELLED", "CANCELLED_AFTER_EFFECT"),
+ Arrays.stream(ActionResult.Outcome.values()).map(Enum::name).toList());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Refusals are an opaque, namespaced code (HIGH-2)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_refusal_carriesOpaqueCode_noRowNoReplay() {
+ var json = Json.of(ActionResult.refusal("write-guard:not-armed"));
+ var actual = Json.to(json, Map.class);
+ assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"refusal\",\"refusalCode\":\"write-guard:not-armed\"}", Map.class), actual, json);
+ }
+
+ @Test void c02_refusal_rendersAnyNamespace() {
+ // The runtime renders ANY code visibly; the type accepts framework codes and consumer app:/write-guard: codes alike.
+ assertTrue(Json.of(ActionResult.refusal("CSRF_TOKEN_MISSING")).contains("\"refusalCode\":\"CSRF_TOKEN_MISSING\""));
+ assertTrue(Json.of(ActionResult.refusal("app:custom-thing")).contains("\"refusalCode\":\"app:custom-thing\""));
+ }
+
+ @Test void c03_refusal_blankCodeThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ActionResult.refusal(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> ActionResult.refusal(null));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Omit-when-unset for every optional field
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_failure_omitsRowReplayRefusalMessage() {
+ var json = Json.of(ActionResult.failure());
+ assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"failure\"}", Map.class), Json.to(json, Map.class), json);
+ for (var k : List.of("row", "replay", "refusalCode", "message"))
+ assertFalse(json.contains("\"" + k + "\""), () -> "unset field leaked: " + k + "\n" + json);
+ }
+
+ @Test void d02_unknown_isHonestTerminalState() {
+ var json = Json.of(ActionResult.unknown());
+ assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"unknown\"}", Map.class), Json.to(json, Map.class), json);
+ }
+
+ @Test void d03_replayFalseOrNull_isOmitted() {
+ assertFalse(Json.of(ActionResult.success(Map.of("id", "x")).replay(false)).contains("\"replay\""));
+ assertFalse(Json.of(ActionResult.success(Map.of("id", "x")).replay(null)).contains("\"replay\""));
+ }
+
+ @Test void d04_replayTrue_isEmitted() {
+ assertTrue(Json.of(ActionResult.success(Map.of("id", "x")).replay(true)).contains("\"replay\":true"));
+ }
+
+ @Test void d05_successWithNullRow_omitsRow() {
+ // A redraw/navigate success carries no row.
+ var json = Json.of(ActionResult.success(null));
+ assertEquals(Json.to("{\"contractVersion\":\"1\",\"outcome\":\"success\"}", Map.class), Json.to(json, Map.class), json);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Fluent setters
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_fluentSetters() {
+ var r = ActionResult.failure().message("boom").refusalCode("app:x").row(Map.of("id", "1"));
+ assertEquals("boom", r.message);
+ assertEquals("app:x", r.refusalCode);
+ assertEquals(Map.of("id", "1"), r.row);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
new file mode 100644
index 0000000000..78000040be
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit test for the in-memory {@link AsyncJobRegistry} (design doc §6.3; {@code TODO-425}, Q2).
+ *
+ * <p>
+ * Pins the load-bearing server-side controls: the unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
+ * capability id (HIGH-4), the concurrent-job cap, the enumeration-resistant lookup, and the deterministic
+ * hard-timeout sweep + retention reaping (driven by an injected clock rather than wall time).
+ */
+@SuppressWarnings({
+ "resource" // AsyncJobRegistry is AutoCloseable; test-fixture lifecycle is managed by the test, not a real leak (mixed-module resource analysis on test code).
+})
+class AsyncJobRegistry_Test extends TestBase {
+
+ /** A test clock whose instant is advanced explicitly, so timeout sweeps are deterministic (no wall-clock waits). */
+ private static final class TestClock extends Clock {
+ private Instant now;
+ TestClock(Instant now) { this.now = now; }
+ void set(Instant v) { now = v; }
+ @Override public Instant instant() { return now; }
+ @Override public ZoneId getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(ZoneId z) { return this; }
+ }
+
+ private static final Instant T0 = Instant.parse("2026-08-19T00:00:00Z");
+
+ /** A registry with no background scheduler (timeouts are enforced only by explicit sweeps) on the given clock. */
+ private static AsyncJobRegistry registry(TestClock clock) {
+ return new AsyncJobRegistry(clock, Duration.ofSeconds(120), AsyncJobRegistry.MAX_OUTPUT_BYTES,
+ AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, null);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The capability id is unguessable: 256-bit SecureRandom, hex, unique (HIGH-4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_capabilityIdIs256BitHex() {
+ var id = registry(new TestClock(T0)).create().id();
+ assertEquals(64, id.length(), id); // 32 bytes -> 64 lowercase hex chars
+ assertTrue(id.matches("[0-9a-f]{64}"), id);
+ assertEquals(256, AsyncJobRegistry.CAPABILITY_BITS);
+ }
+
+ @Test void a02_capabilityIdsAreUnique() {
+ var r = registry(new TestClock(T0));
+ // Settle each job as it is minted so the concurrency cap never blocks the sample (a terminal job frees its
+ // running slot); we are pinning id uniqueness, not the cap (that is c01).
+ var seen = Stream.generate(() -> {
+ var job = r.create();
+ job.complete(ActionResult.success(null));
+ return job.id();
+ }).limit(50).collect(Collectors.toSet());
+ assertEquals(50, seen.size(), "minted capability ids must be unique (SecureRandom)");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Enumeration-resistant lookup: an unknown id is empty, indistinguishable from any other (HIGH-4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_getUnknownIsEmpty() {
+ var r = registry(new TestClock(T0));
+ var job = r.create();
+ assertTrue(r.get(job.id()).isPresent());
+ assertTrue(r.get("deadbeef").isEmpty());
+ assertTrue(r.get("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").isEmpty());
+ assertTrue(r.get(null).isEmpty());
+ assertTrue(r.get(" ").isEmpty());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Concurrent-job cap: the 9th is refused; a freed slot re-opens it (MED-6)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_concurrentJobCapRefusesNinth() {
+ var r = registry(new TestClock(T0));
+ var jobs = new ArrayList<AsyncJob>();
+ for (var i = 0; i < AsyncJobRegistry.MAX_CONCURRENT_JOBS; i++)
+ jobs.add(r.tryCreate().orElseThrow());
+ assertEquals(8, AsyncJobRegistry.MAX_CONCURRENT_JOBS);
+ assertEquals(8, r.runningCount());
+ assertTrue(r.tryCreate().isEmpty(), "9th concurrent job must be refused");
+ // create() (the throwing form) signals the same cap.
+ assertThrows(IllegalStateException.class, r::create);
+ // Settling one frees a slot (terminal jobs do not count as concurrent).
+ jobs.get(0).complete(ActionResult.success(null));
+ assertEquals(7, r.runningCount());
+ assertTrue(r.tryCreate().isPresent(), "a settled job frees a concurrency slot");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Hard-timeout sweep (deterministic via the injected clock) - a data-egress bound (HIGH-4/MED-6/Task-11)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_sweepTimesOutExpiredJobs() {
+ var clock = new TestClock(T0);
+ var r = registry(clock);
+ var job = r.create();
+ clock.set(T0.plusSeconds(121));
+ r.sweepTimeouts();
+ assertTrue(job.isTerminal());
+ assertEquals("unknown", job.result().outcome);
+ }
+
+ @Test void d02_tryCreateSweepsSoAnExpiredJobFreesItsSlot() {
+ var clock = new TestClock(T0);
+ var r = registry(clock);
+ for (var i = 0; i < AsyncJobRegistry.MAX_CONCURRENT_JOBS; i++)
+ r.tryCreate().orElseThrow();
+ assertTrue(r.tryCreate().isEmpty()); // full
+ clock.set(T0.plusSeconds(121)); // every job is now past its deadline
+ assertTrue(r.tryCreate().isPresent(), "tryCreate must sweep expired jobs and reopen a slot");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Retention reaping: a terminal job is kept for re-attach, then reaped
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_terminalJobRetainedThenReaped() {
+ var clock = new TestClock(T0);
+ var r = registry(clock);
+ var job = r.create();
+ job.complete(ActionResult.success(null));
+ // Within retention: still present (a page reload can re-attach and read the result).
+ clock.set(T0.plusSeconds(30));
+ r.sweepTimeouts();
+ assertTrue(r.get(job.id()).isPresent());
+ // Past retention: reaped from the heap.
+ clock.set(T0.plus(AsyncJobRegistry.RETENTION).plusSeconds(1));
+ r.sweepTimeouts();
+ assertTrue(r.get(job.id()).isEmpty());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Cancel by id (Q4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_cancelById() {
+ var r = registry(new TestClock(T0));
+ var job = r.create();
+ var cancelled = r.cancel(job.id());
+ assertTrue(cancelled.isPresent());
+ assertEquals("cancelled", cancelled.get().result().outcome);
+ assertTrue(r.cancel("no-such-id").isEmpty());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) A registry-minted job carries the configured subscriber cap (MED-6)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_mintedJobCarriesSubscriberCap() {
+ var job = registry(new TestClock(T0)).create();
+ assertEquals(AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, job.maxSubscribers());
+ assertEquals(AsyncJobRegistry.MAX_OUTPUT_BYTES, job.maxOutputBytes());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // h) The default (production) registry mints a private daemon scheduler that closes cleanly
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_defaultRegistryCreatesAndCloses() {
+ try (var r = new AsyncJobRegistry()) {
+ var job = r.create();
+ assertNotNull(job.id());
+ assertEquals(120, AsyncJobRegistry.HARD_TIMEOUT.getSeconds());
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java
new file mode 100644
index 0000000000..69acc98055
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_BrowserTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The async-job + SSE-streaming half of the module's <b>JavaScript-execution harness</b>: runs the REAL served
+ * {@code juneau-views.js} in a real headless browser and asserts the TODO-425 behavior as a user would experience it
+ * — an async row action's start POST returns a job pointer, the row picks up the DISTINCT
+ * {@code data-juneau-job} affordance (never {@code data-juneau-inflight}, so the table KEEPS polling), streamed
+ * progress renders in a visible {@code textContent} banner, the single terminal {@code result} event settles the row
+ * to success / cancelled / cancelled-after-effect, a stream error settles it to a non-optimistic unknown, and Cancel
+ * issues a fail-closed CSRF POST to the job's cancel URL.
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_AsyncJobs_Test} proves the shipped script <i>contains</i> the async/streaming logic; it cannot
+ * prove that at runtime a running job leaves table polling live (the load-bearing HIGH-9 fact), that progress and the
+ * terminal outcome actually paint, or that a stream error degrades to a visible unknown. This canary drives the real
+ * runtime in Chromium against a controllable fake {@code EventSource}, so those user-visible facts are measured
+ * rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code async-job.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ModalResult_BrowserTest} — the sibling declarative-modal/typed-result canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=AsyncJob_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class AsyncJob_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("async-job.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("async-job.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("async-job-stdout.json");
+ var stderr = dir.resolve("async-job-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> sub(String key) {
+ return (Map<String,Object>) report.get(key);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The runtime loaded, at the typed-action-result contract version, with no console errors
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_runtimeLoaded() {
+ assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+ assertEquals(ActionResult.CONTRACT_VERSION, report.get("actionResultContractVersion"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) HIGH-9: a running job uses the DISTINCT marker and does NOT freeze the table's polling
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_runningJobUsesDistinctMarker_andDoesNotFreezePolling() {
+ var r = sub("running");
+ assertEquals(Boolean.TRUE, r.get("hasJobMarker"), () -> "row did not pick up data-juneau-job: " + report);
+ // The whole point of HIGH-9: a job must NOT set the synchronous in-flight marker that freezes polling.
+ assertEquals(Boolean.FALSE, r.get("hasInflightMarker"), () -> "job wrongly set data-juneau-inflight: " + report);
+ assertEquals(Boolean.FALSE, r.get("pollingFrozen"), () -> "polling was frozen during the job (HIGH-9 violation): " + report);
+ assertEquals(Boolean.TRUE, r.get("progressVisible"), () -> "job progress banner not visible: " + report);
+ assertEquals("Working\u2026", r.get("progressText"), () -> report.toString());
+ assertEquals(Boolean.TRUE, r.get("triggerDisabled"), () -> "trigger not disabled while job runs: " + report);
+ assertEquals(Boolean.TRUE, r.get("esOpened"), () -> "no EventSource opened: " + report);
+ assertEquals("/juneau-jobs/cap/stream", r.get("esUrl"), () -> "EventSource opened on the wrong (capability) URL: " + report);
+ }
+
+ @Test void b02_progressEventUpdatesBanner_andPollingStaysLive() {
+ var p = sub("progress");
+ assertEquals("step 2 of 3", p.get("text"), () -> "streamed progress did not render: " + report);
+ assertEquals(Boolean.FALSE, p.get("pollingFrozenDuringProgress"), () -> "polling froze during progress: " + report);
+ assertEquals(Boolean.FALSE, p.get("outcomeYet"), () -> "an outcome rendered before the terminal event: " + report);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) The terminal `result` event settles the row (success): banner cleared, marker removed, trigger re-enabled
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_terminalResultSettlesRowToSuccess() {
+ var s = sub("settledSuccess");
+ assertEquals(Boolean.TRUE, s.get("esClosed"), () -> "EventSource not closed on settle: " + report);
+ assertEquals(Boolean.TRUE, s.get("jobMarkerCleared"), () -> "data-juneau-job not cleared on settle: " + report);
+ assertEquals(Boolean.TRUE, s.get("progressCleared"), () -> "progress banner not cleared on settle: " + report);
+ assertEquals(Boolean.TRUE, s.get("triggerReEnabled"), () -> "trigger not re-enabled on settle: " + report);
+ assertEquals(Boolean.FALSE, s.get("pollingFrozen"), () -> "polling frozen after settle: " + report);
+ assertNotNull(s.get("mergedRow"), () -> "success did not re-render from the authoritative result row: " + report);
+ @SuppressWarnings("unchecked")
+ var outcome = (Map<String,Object>) s.get("outcome");
+ assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "success banner not visible: " + report);
+ assertEquals("success", outcome.get("state"), () -> report.toString());
+ assertEquals("status", outcome.get("role"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Cancel issues a fail-closed CSRF POST; the SERVER-authoritative terminal outcome arrives over SSE (Q4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_cancelIssuesFailClosedCsrfPost() {
+ var cancel = sub("cancel");
+ assertEquals(Boolean.TRUE, cancel.get("cancelBtnVisible"), () -> "cancel button not visible: " + report);
+ assertEquals(Boolean.TRUE, cancel.get("postIssued"), () -> "cancel did not issue a POST: " + report);
+ assertEquals("/juneau-jobs/cap/cancel", cancel.get("url"), () -> "cancel POST hit the wrong URL: " + report);
+ assertEquals("POST", cancel.get("method"), () -> "cancel must be a non-safe POST: " + report);
+ assertEquals("tok-xyz", cancel.get("csrfHeader"), () -> "cancel POST did not carry the CSRF token: " + report);
+ }
+
+ @Test void d02_cancelledOutcomeArrivesOverStream() {
+ var cancel = sub("cancel");
+ @SuppressWarnings("unchecked")
+ var outcome = (Map<String,Object>) cancel.get("outcome");
+ assertEquals("cancelled", outcome.get("state"), () -> report.toString());
+ assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "cancelled banner not visible: " + report);
+ assertTrue(String.valueOf(outcome.get("text")).contains("Cancelled"), () -> report.toString());
+ assertEquals(Boolean.TRUE, cancel.get("jobMarkerCleared"), () -> "job marker not cleared on cancel: " + report);
+ }
+
+ @Test void d03_cancelledAfterEffectIsADistinctOutcome() {
+ // Q4: cancelled-after-effect must NOT be collapsed into plain cancelled - it is its own rendered outcome.
+ var o = sub("cancelledAfterEffect");
+ assertEquals("cancelled-after-effect", o.get("state"), () -> report.toString());
+ assertEquals(Boolean.TRUE, o.get("visible"), () -> "cancelled-after-effect banner not visible: " + report);
+ assertTrue(String.valueOf(o.get("text")).contains("partial effect"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) A stream error is itself a non-optimistic terminal outcome; polling was never frozen
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_streamErrorRendersVisibleUnknown() {
+ var s = sub("streamError");
+ @SuppressWarnings("unchecked")
+ var outcome = (Map<String,Object>) s.get("outcome");
+ assertEquals("unknown", outcome.get("state"), () -> report.toString());
+ assertEquals(Boolean.TRUE, outcome.get("visible"), () -> "stream-error unknown banner not visible: " + report);
+ assertEquals(Boolean.TRUE, s.get("jobMarkerCleared"), () -> "job marker not cleared on stream error: " + report);
+ assertEquals(Boolean.FALSE, s.get("pollingFrozen"), () -> "polling frozen after stream error: " + report);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java
new file mode 100644
index 0000000000..1d656527ba
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJob_Test.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit test for the in-memory one-shot {@link AsyncJob} (design doc §6.3; {@code TODO-425}).
+ *
+ * <p>
+ * Covers the server-side hard limits that are BOTH heap bounds and Task-11 data-egress disclosure bounds (MED-6):
+ * the per-job output cap and the hard timeout; the reserved async terminal outcomes (cancelled vs
+ * cancelled-after-effect, Q4); the subscriber cap; and the progress buffering / await used by the SSE stream.
+ */
+class AsyncJob_Test extends TestBase {
+
+ private static AsyncJob job() {
+ return job(AsyncJobRegistry.MAX_OUTPUT_BYTES, AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, Duration.ofSeconds(120));
+ }
+
+ private static AsyncJob job(long maxOutputBytes, int maxSubscribers, Duration timeout) {
+ return new AsyncJob("cap-id", Instant.parse("2026-08-19T00:00:00Z"), timeout, maxOutputBytes, maxSubscribers);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Progress buffering + re-attach snapshot
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_progressBuffersEvents() {
+ var j = job();
+ assertTrue(j.progress("one"));
+ assertTrue(j.progress("two"));
+ assertEquals(2, j.eventCount());
+ assertEquals(java.util.List.of("one", "two"), j.eventsFrom(0));
+ assertEquals(java.util.List.of("two"), j.eventsFrom(1));
+ assertEquals(java.util.List.of(), j.eventsFrom(5));
+ }
+
+ @Test void a02_idAccessor() {
+ assertEquals("cap-id", job().id());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Terminal settling (first settle wins) + reserved async outcomes (Q4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_completeSettlesOnce() {
+ var j = job();
+ assertTrue(j.complete(ActionResult.success(null)));
+ assertTrue(j.isTerminal());
+ assertEquals("success", j.result().outcome);
+ // A second settle is a no-op; the first outcome stands.
+ assertFalse(j.complete(ActionResult.failure()));
+ assertEquals("success", j.result().outcome);
+ }
+
+ @Test void b02_cancelBeforeEffectIsCancelled() {
+ var j = job();
+ assertTrue(j.cancel());
+ assertEquals("cancelled", j.result().outcome);
+ }
+
+ @Test void b03_cancelAfterEffectIsCancelledAfterEffect() {
+ // Q4: "cancelled" and "cancelled-after-effect" are DIFFERENT outcomes and must not be collapsed.
+ var j = job();
+ j.markEffectStarted();
+ assertTrue(j.effectStarted());
+ assertTrue(j.cancel());
+ assertEquals("cancelled-after-effect", j.result().outcome);
+ }
+
+ @Test void b04_cancelAfterTerminalIsNoOp() {
+ var j = job();
+ j.complete(ActionResult.success(null));
+ assertFalse(j.cancel());
+ assertEquals("success", j.result().outcome);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Output cap - a data-egress disclosure bound, enforced server-side (MED-6)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_outputCapTerminatesJobAndDropsExcess() {
+ var j = job(10, 2, Duration.ofSeconds(120));
+ assertTrue(j.progress("12345")); // 5 bytes -> ok
+ assertFalse(j.progress("123456")); // +6 -> 11 > 10 -> terminate, drop this event
+ assertTrue(j.isTerminal());
+ assertEquals("failure", j.result().outcome);
+ assertTrue(j.result().message.contains("output-limit"), j.result().message);
+ assertEquals(1, j.eventCount(), "the over-cap event must be dropped, not streamed");
+ // Once terminal, further progress is a no-op.
+ assertFalse(j.progress("x"));
+ assertEquals(1, j.eventCount());
+ }
+
+ @Test void c02_outputBytesTracked() {
+ var j = job();
+ j.progress("abc"); // 3 bytes
+ j.progress("de"); // 2 bytes
+ assertEquals(5, j.outputBytes());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Hard timeout - a data-egress disclosure bound, enforced server-side (HIGH-4/MED-6/Task-11)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_timeoutBeforeDeadlineIsNoOp() {
+ var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+ assertFalse(j.enforceTimeout(j.createdAt().plusSeconds(119)));
+ assertFalse(j.isTerminal());
+ }
+
+ @Test void d02_timeoutPastDeadlineSettlesUnknown() {
+ var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+ assertTrue(j.enforceTimeout(j.createdAt().plusSeconds(121)));
+ assertTrue(j.isTerminal());
+ assertEquals("unknown", j.result().outcome);
+ assertTrue(j.result().message.contains("hard-timeout"), j.result().message);
+ }
+
+ @Test void d03_deadlineIsCreatedAtPlusTimeout() {
+ var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+ assertEquals(j.createdAt().plusSeconds(120), j.deadline());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) awaitUpdate: drains a terminal job in one pass; blocks then wakes for a live event
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_awaitUpdateReturnsBufferedEventsAndResultForTerminalJob() {
+ var j = job();
+ j.progress("p1");
+ j.progress("p2");
+ j.complete(ActionResult.success(null));
+ var u = j.awaitUpdate(0, Duration.ofMillis(10));
+ assertEquals(java.util.List.of("p1", "p2"), u.events());
+ assertNotNull(u.result());
+ assertEquals("success", u.result().outcome);
+ }
+
+ @Test void e02_awaitUpdateBlocksThenWakesOnLiveEvent() throws Exception {
+ var j = job();
+ var pool = Executors.newSingleThreadScheduledExecutor();
+ try {
+ pool.schedule(() -> j.progress("live"), 50, TimeUnit.MILLISECONDS);
+ var u = j.awaitUpdate(0, Duration.ofSeconds(2)); // blocks until the scheduled progress arrives
+ assertEquals(java.util.List.of("live"), u.events());
+ assertNull(u.result());
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test void e03_awaitUpdateTimesOutEmptyWhileRunning() {
+ var j = job();
+ var u = j.awaitUpdate(0, Duration.ofMillis(20));
+ assertEquals(java.util.List.of(), u.events());
+ assertNull(u.result());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Subscriber cap (MED-6)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_subscriberCapEnforced() {
+ var j = job(AsyncJobRegistry.MAX_OUTPUT_BYTES, 2, Duration.ofSeconds(120));
+ assertTrue(j.acquireSubscriber());
+ assertTrue(j.acquireSubscriber());
+ assertFalse(j.acquireSubscriber(), "3rd subscriber must be refused (cap=2)");
+ assertEquals(2, j.subscriberCount());
+ j.releaseSubscriber();
+ assertTrue(j.acquireSubscriber(), "a freed slot can be re-acquired");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) The id is a secret - toString must not leak it
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_toStringRedactsId() {
+ var j = new AsyncJob("super-secret-capability", Instant.now(), Duration.ofSeconds(120), 1024, 2);
+ var s = j.toString();
+ assertFalse(s.contains("super-secret-capability"), s);
+ assertTrue(s.contains("<redacted>"), s);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java
new file mode 100644
index 0000000000..35e8ee5a37
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobsMixin_Serving_Test.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving test for {@link AsyncJobsMixin} — the SSE progress stream and cancel endpoints (design doc §6.3;
+ * {@code TODO-425}).
+ *
+ * <p>
+ * Drives the two endpoints through a {@link MockRestClient}: the stream replays a completed job's buffered progress
+ * plus its terminal {@code result} event (the page-reload re-attach path, Q3); an unknown capability id is a
+ * {@code 404} (enumeration-resistant, HIGH-4); a job already at its subscriber cap is a {@code 429} (MED-6); and
+ * cancel returns the terminal {@code cancelled} / {@code cancelled-after-effect} outcome (Q4).
+ *
+ * <p>
+ * Each SSE assertion completes the job <b>before</b> subscribing, so the stream drains in one deterministic pass
+ * with no wall-clock waits (a still-running job would legitimately hold the stream open until its hard timeout).
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class AsyncJobsMixin_Serving_Test extends TestBase {
+
+ @Rest
+ public static class R extends BasicRestServlet implements AsyncJobsMixin {
+ private static final long serialVersionUID = 1L;
+
+ // No background scheduler: the serving tests do not exercise wall-clock timeout, so nothing runs off-thread.
+ static final AsyncJobRegistry REGISTRY = new AsyncJobRegistry(Clock.systemUTC(), Duration.ofSeconds(120),
+ AsyncJobRegistry.MAX_OUTPUT_BYTES, AsyncJobRegistry.MAX_SUBSCRIBERS_PER_JOB, null);
+
+ static volatile AsyncJob lastJob;
+
+ @Override public AsyncJobRegistry asyncJobRegistry() { return REGISTRY; }
+
+ /** Test-only: starts a job (as a consumer's async row-action POST would) and returns its AsyncJobRef pointer. */
+ @RestPost(path="/start") public AsyncJobRef start() {
+ var job = REGISTRY.create();
+ lastJob = job;
+ return AsyncJobRef.of(job);
+ }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(R.class);
+
+ private static String startJob() throws Exception {
+ var body = c.post("/start", "").header("Accept", "application/json").run().assertStatus(200).getContent().asString();
+ return String.valueOf(Json.to(body, Map.class).get("jobId"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The job-started envelope is an AsyncJobRef with capability URLs embedding the job id (no RowAction field)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_startReturnsAsyncJobRefWithCapabilityUrls() throws Exception {
+ var body = c.post("/start", "").header("Accept", "application/json").run().assertStatus(200).getContent().asString();
+ var ref = Json.to(body, Map.class);
+ var jobId = String.valueOf(ref.get("jobId"));
+ assertTrue(jobId.matches("[0-9a-f]{64}"), jobId);
+ assertEquals("servlet:" + AsyncJobsMixin.streamPath(jobId), ref.get("streamUrl"));
+ assertEquals("servlet:" + AsyncJobsMixin.cancelPath(jobId), ref.get("cancelUrl"));
+ // The pointer carries NO ActionResult outcome discriminator - the two 2xx shapes are disjoint.
+ assertNull(ref.get("outcome"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) The stream replays buffered progress + one terminal result event (re-attach, Q3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_streamReplaysProgressThenResult() throws Exception {
+ var jobId = startJob();
+ var job = R.lastJob;
+ job.progress("step 1 of 2");
+ job.progress("step 2 of 2");
+ job.complete(ActionResult.success(Map.of("id", "INC-1", "status", "acknowledged")));
+
+ var resp = c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run().assertStatus(200);
+ resp.assertHeader("Content-Type").isContains("text/event-stream");
+ var body = resp.getContent().asString();
+ assertTrue(body.contains("event: progress"), body);
+ assertTrue(body.contains("data: step 1 of 2"), body);
+ assertTrue(body.contains("data: step 2 of 2"), body);
+ assertTrue(body.contains("event: result"), body);
+ assertTrue(body.contains("\"outcome\":\"success\""), body);
+ assertTrue(body.contains("INC-1"), body);
+ }
+
+ @Test void b02_streamOfCancelledJobCarriesCancelledResult() throws Exception {
+ var jobId = startJob();
+ R.lastJob.progress("working");
+ R.lastJob.cancel();
+ var body = c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run()
+ .assertStatus(200).getContent().asString();
+ assertTrue(body.contains("event: result"), body);
+ assertTrue(body.contains("\"outcome\":\"cancelled\""), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Capability gating: an unknown id is 404 (enumeration-resistant, HIGH-4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_unknownJobIdStreamIs404() throws Exception {
+ c.get(AsyncJobsMixin.streamPath("00000000000000000000000000000000")).header("Accept", "text/event-stream")
+ .run().assertStatus(404);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Subscriber cap: a job already at its cap answers 429 (MED-6)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_subscriberCapReturns429() throws Exception {
+ var jobId = startJob();
+ var job = R.lastJob;
+ // Pre-occupy both subscriber slots so the endpoint's own acquire fails; complete so the stream would drain.
+ assertTrue(job.acquireSubscriber());
+ assertTrue(job.acquireSubscriber());
+ job.complete(ActionResult.success(null));
+ c.get(AsyncJobsMixin.streamPath(jobId)).header("Accept", "text/event-stream").run().assertStatus(429);
+ job.releaseSubscriber();
+ job.releaseSubscriber();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Cancel returns the terminal outcome, distinguishing cancelled from cancelled-after-effect (Q4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_cancelReturnsCancelled() throws Exception {
+ var jobId = startJob();
+ var body = c.post(AsyncJobsMixin.cancelPath(jobId), "").header("Accept", "application/json").run()
+ .assertStatus(200).getContent().asString();
+ assertTrue(body.contains("\"outcome\":\"cancelled\""), body);
+ assertFalse(body.contains("cancelled-after-effect"), body);
+ }
+
+ @Test void e02_cancelAfterEffectReturnsCancelledAfterEffect() throws Exception {
+ var jobId = startJob();
+ R.lastJob.markEffectStarted();
+ var body = c.post(AsyncJobsMixin.cancelPath(jobId), "").header("Accept", "application/json").run()
+ .assertStatus(200).getContent().asString();
+ assertTrue(body.contains("\"outcome\":\"cancelled-after-effect\""), body);
+ }
+
+ @Test void e03_cancelUnknownJobIs404AndNamedRefusal() throws Exception {
+ var body = c.post(AsyncJobsMixin.cancelPath("00000000000000000000000000000000"), "")
+ .header("Accept", "application/json").run().assertStatus(404).getContent().asString();
+ assertTrue(body.contains("\"outcome\":\"refusal\""), body);
+ assertTrue(body.contains("app:unknown-job"), body);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java
new file mode 100644
index 0000000000..456d4ce906
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/IdempotencyKey_Test.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.stream.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Test for the server-minted, {@code (action, targetId)}-bound {@link IdempotencyKey} (design doc §6.2; the
+ * idempotency half of {@code TODO-416}, HIGH-8).
+ *
+ * <p>
+ * The security property is the <b>binding</b>, not the width: a key submitted against a different action or target
+ * than it was minted for must be a refusal, never a replayed success.
+ */
+class IdempotencyKey_Test extends TestBase {
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Minting: unforgeable width + binding
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_mint_isBoundToActionAndTarget() {
+ var k = IdempotencyKey.mint("ack", "INC-1");
+ assertEquals("ack", k.action());
+ assertEquals("INC-1", k.targetId());
+ assertNotNull(k.value());
+ assertFalse(k.value().isBlank());
+ }
+
+ @Test void a02_mint_valueIs256BitsHex() {
+ // 32 bytes -> 64 lowercase hex chars.
+ var v = IdempotencyKey.mint("ack", "INC-1").value();
+ assertEquals(64, v.length(), v);
+ assertTrue(v.matches("[0-9a-f]{64}"), v);
+ }
+
+ @Test void a03_mint_valuesAreUnique() {
+ var seen = Stream.generate(() -> IdempotencyKey.mint("ack", "INC-1").value()).limit(50).collect(Collectors.toSet());
+ assertEquals(50, seen.size(), "minted key values must be unique (SecureRandom)");
+ }
+
+ @Test void a04_mint_blankArgsThrow() {
+ assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.mint(null, "x"));
+ assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.mint("a", " "));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Binding check (HIGH-8): mismatch is a refusal, never a replayed success
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_matches_onlyForSameActionAndTarget() {
+ var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+ assertTrue(k.matches("ack", "INC-1"));
+ }
+
+ @Test void b02_matches_falseForDifferentTarget() {
+ // The A-replayed-under-B attack: same key/action, different target -> must NOT match.
+ var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+ assertFalse(k.matches("ack", "INC-2"));
+ }
+
+ @Test void b03_matches_falseForDifferentAction() {
+ var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+ assertFalse(k.matches("resolve", "INC-1"));
+ }
+
+ @Test void b04_matches_falseForNulls() {
+ var k = IdempotencyKey.of("deadbeef", "ack", "INC-1");
+ assertFalse(k.matches(null, "INC-1"));
+ assertFalse(k.matches("ack", null));
+ }
+
+ @Test void b05_of_rehydratesRecordedBinding() {
+ var k = IdempotencyKey.of("v", "ack", "INC-1");
+ assertEquals("v", k.value());
+ assertEquals("ack", k.action());
+ assertEquals("INC-1", k.targetId());
+ }
+
+ @Test void b06_of_blankArgsThrow() {
+ assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of(" ", "a", "t"));
+ assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of("v", null, "t"));
+ assertThrows(IllegalArgumentException.class, () -> IdempotencyKey.of("v", "a", " "));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The value is a secret - toString must not leak it
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_toString_redactsValue() {
+ var k = IdempotencyKey.of("super-secret-value", "ack", "INC-1");
+ var s = k.toString();
+ assertFalse(s.contains("super-secret-value"), s);
+ assertTrue(s.contains("<redacted>"), s);
+ assertTrue(s.contains("ack"), s);
+ assertTrue(s.contains("INC-1"), s);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
new file mode 100644
index 0000000000..f7a54274de
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Contract test for the declarative modal ({@link ModalDef}) + form ({@link FormDef}) payload the modal-open
+ * confirmation fetch returns (design doc §6.2; the modal/form half of {@code TODO-416}).
+ *
+ * <p>
+ * The confirmation body is typed structured fields painted client-side with {@code textContent} (never
+ * {@code innerHTML}); this test pins that field shape and the FreeMarker-first form source, plus the omit-when-unset
+ * rule for the optional form and idempotency key.
+ */
+class ModalDef_FormDef_Test extends TestBase {
+
+ //------------------------------------------------------------------------------------------------------------------
+ // ModalDef wire shape
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_fullModal_serializesToFrozenShape() {
+ var modal = ModalDef.create("Acknowledge this incident?")
+ .field("Incident", "INC-42")
+ .field("Title", "API latency")
+ .field("Service", "gateway")
+ .field("Current status", "triggered")
+ .form(FormDef.ofTemplate("servlet:/incidents/ack-form.ftl"))
+ .idempotencyKey("deadbeef");
+ var json = Json.of(modal);
+ var expected = Json.to("""
+ {"title":"Acknowledge this incident?",
+ "fields":[{"label":"Incident","value":"INC-42"},{"label":"Title","value":"API latency"},
+ {"label":"Service","value":"gateway"},{"label":"Current status","value":"triggered"}],
+ "form":{"template":"servlet:/incidents/ack-form.ftl"},
+ "idempotencyKey":"deadbeef"}
+ """, Map.class);
+ assertEquals(expected, Json.to(json, Map.class), json);
+ }
+
+ @Test void a02_topLevelKeyOrder() {
+ var modal = ModalDef.create("t").field("A", "1").form(FormDef.ofTemplate("u")).idempotencyKey("k");
+ Map<?,?> actual = Json.to(Json.of(modal), Map.class);
+ assertEquals(List.of("title", "fields", "form", "idempotencyKey"), new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void a03_confirmOnlyModal_omitsFieldsFormAndKey() {
+ // A bare confirm-only modal (no fields, no form, no key) - every optional field omitted, not null.
+ var json = Json.of(ModalDef.create("Really delete?"));
+ assertEquals(Json.to("{\"title\":\"Really delete?\"}", Map.class), Json.to(json, Map.class), json);
+ for (var k : List.of("fields", "form", "idempotencyKey"))
+ assertFalse(json.contains("\"" + k + "\""), () -> "unset field leaked: " + k + "\n" + json);
+ }
+
+ @Test void a04_fieldNullValue_serializesLabelOnly() {
+ // A field value may be null (rendered as an empty value client-side); the label is required.
+ var json = Json.of(ModalDef.create("t").field("Note", null));
+ assertTrue(json.contains("\"label\":\"Note\""), json);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Validation guards
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_createBlankTitleThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ModalDef.create(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.create(null));
+ }
+
+ @Test void b02_fieldBlankLabelThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.of(" ", "v"));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.of(null, "v"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // FormDef (FreeMarker-first)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_formDef_serializesTemplateOnly() {
+ var json = Json.of(FormDef.ofTemplate("servlet:/x/form.ftl"));
+ assertEquals(Json.to("{\"template\":\"servlet:/x/form.ftl\"}", Map.class), Json.to(json, Map.class), json);
+ }
+
+ @Test void c02_formDef_reservedBeanTypeIsNotOnTheWireYet() {
+ // The bean->form generator is a deferred follow-on; no "beanType" key leaks in the MVP.
+ assertFalse(Json.of(FormDef.ofTemplate("u")).contains("beanType"));
+ }
+
+ @Test void c03_formDef_blankTemplateThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> FormDef.ofTemplate(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> FormDef.ofTemplate(null));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
new file mode 100644
index 0000000000..cc497eaf4a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The declarative-modal + typed-action-result + in-flight-row half of the module's <b>JavaScript-execution
+ * harness</b>: runs the REAL served {@code juneau-views.js} in a real headless browser and asserts the TODO-416/417
+ * behavior as a user would experience it — a {@code present=dialog} action opens an overlay whose typed
+ * confirmation fields are painted with {@code textContent} (so an HTML-shaped value never becomes an element), every
+ * settled outcome (success/failure/refusal/unknown) and a non-2xx transport refusal render a VISIBLE non-optimistic
+ * banner, the in-flight marker is set while the sync write is outstanding and CLEARED on every terminal outcome
+ * (including UNKNOWN), and polling is no longer frozen once the marker clears.
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_ModalResult_Test} proves the shipped script <i>contains</i> the modal/result/in-flight logic; it
+ * cannot prove the overlay renders, the HTML-shaped field value stays inert text, or the marker actually clears on
+ * an UNKNOWN outcome at runtime. This canary drives the real runtime in Chromium against fabricated responses, so
+ * those user-visible facts are measured rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code modal-result.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link RowActionCsrf_BrowserTest} — the sibling fail-closed-CSRF canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=ModalResult_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ModalResult_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("modal-result.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("modal-result.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("modal-result-stdout.json");
+ var stderr = dir.resolve("modal-result-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> sub(String key) {
+ return (Map<String,Object>) report.get(key);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The runtime loaded, at the typed-action-result contract version (its OWN version, not the view's)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_runtimeLoadedAtActionResultContractVersion() {
+ assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+ assertEquals(ActionResult.CONTRACT_VERSION, report.get("actionResultContractVersion"), () -> report.toString());
+ assertEquals("1", ActionResult.CONTRACT_VERSION);
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Every 2xx typed outcome renders a VISIBLE banner, and the in-flight marker is cleared (polling resumes)
+ //------------------------------------------------------------------------------------------------------------------
+
+ private void assertTerminal(String key, String expectedState, String expectedRole) {
+ var o = sub(key);
+ assertEquals(Boolean.TRUE, o.get("wasInflightBefore"), () -> key + ": marker should have been set before settle: " + o);
+ assertEquals(Boolean.TRUE, o.get("visible"), () -> key + ": outcome banner not visible: " + o);
+ assertEquals(expectedState, o.get("state"), () -> key + ": wrong state: " + o);
+ assertEquals(expectedRole, o.get("role"), () -> key + ": wrong role: " + o);
+ // The 417 hard requirement: the marker is cleared on EVERY terminal outcome, so polling is not frozen.
+ assertEquals(Boolean.FALSE, o.get("inflight"), () -> key + ": in-flight marker not cleared: " + o);
+ assertEquals(Boolean.FALSE, o.get("pollingFrozen"), () -> key + ": polling still frozen: " + o);
+ assertEquals(Boolean.FALSE, o.get("triggerDisabled"), () -> key + ": trigger not re-enabled: " + o);
+ }
+
+ @Test void b01_successRendersStatusAndClearsMarker() {
+ assertTerminal("success", "success", "status");
+ }
+
+ @Test void b02_failureRendersAlertAndClearsMarker() {
+ assertTerminal("failure", "failure", "alert");
+ assertTrue(String.valueOf(sub("failure").get("text")).contains("nope"), () -> report.toString());
+ }
+
+ @Test void b03_refusalRendersNamedCodeAndClearsMarker() {
+ assertTerminal("refusal", "refusal", "alert");
+ assertTrue(String.valueOf(sub("refusal").get("text")).contains("write-guard:not-armed"), () -> report.toString());
+ }
+
+ @Test void b04_unknownRendersVisibleAndClearsMarker() {
+ // The load-bearing 417 case: an UNKNOWN outcome MUST clear the marker or the whole table's polling freezes.
+ assertTerminal("unknown", "unknown", "alert");
+ }
+
+ @Test void b05_contractMismatchRendersUnknownNotOptimisticSuccess() {
+ assertTerminal("contractMismatch", "unknown", "alert");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) A non-2xx is a VISIBLE transport refusal read from the boundary envelope - not "HTTP 200 + schema" (HIGH-3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_http403RendersTransportRefusalFromEnvelope() {
+ assertTerminal("transport403", "refusal", "alert");
+ var text = String.valueOf(sub("transport403").get("text"));
+ assertTrue(text.contains("CSRF_TOKEN_MISSING"), () -> "boundary reason not surfaced: " + report);
+ assertTrue(text.contains("missing token"), () -> "envelope message not surfaced: " + report);
+ }
+
+ @Test void c02_http421WithNoBodyStillRendersVisibleRefusal() {
+ // No X-Loopback-Boundary header, empty body -> still a visible, comprehensible non-optimistic refusal.
+ assertTerminal("transport421", "refusal", "alert");
+ assertTrue(String.valueOf(sub("transport421").get("text")).contains("421"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Re-render from the authoritative result payload (MERGE_ROW)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_successMergesAuthoritativeRow() {
+ assertNotNull(report.get("mergedRow"), () -> "success did not re-render from the result row: " + report);
+ assertEquals("1", report.get("mergeMarkedRow"), () -> report.toString());
+ assertNotNull(report.get("mergeCtxRow"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) The modal overlay + typed-field confirmation via textContent (XSS-safe), then a bound submit
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_modalOverlayOpensWithTypedFields() {
+ var m = sub("modal");
+ assertEquals(Boolean.TRUE, m.get("backdropVisible"), () -> "modal overlay not visible: " + report);
+ assertEquals("Acknowledge this incident?", m.get("title"), () -> report.toString());
+ assertEquals(2L, ((Number) m.get("fieldCount")).longValue(), () -> report.toString());
+ }
+
+ @Test void e02_htmlShapedFieldValueStaysInertText() {
+ // BLK-1/MED-9: the field value is painted with textContent, so an <img onerror> becomes literal text and
+ // NO <img> element is created anywhere in the dialog.
+ var m = sub("modal");
+ assertEquals("<img src=x onerror=alert(1)>", m.get("evilFieldText"), () -> report.toString());
+ assertEquals(0L, ((Number) m.get("injectedImgCount")).longValue(),
+ () -> "an HTML-shaped field value was parsed into an element - textContent invariant broken: " + report);
+ }
+
+ @Test void e03_confirmSubmitCarriesIdempotencyKeyAndTargetId() {
+ var m = sub("modal");
+ assertEquals(Boolean.TRUE, m.get("submitIssued"), () -> "confirm did not submit: " + report);
+ var body = String.valueOf(m.get("submitBody"));
+ assertTrue(body.contains("key-abc"), () -> "idempotency key not carried on submit: " + report);
+ assertTrue(body.contains("INC-1"), () -> "target id not carried on submit: " + report);
+ assertTrue(body.contains("ack"), () -> "action id not carried on submit: " + report);
+ assertEquals(Boolean.TRUE, m.get("backdropClosedAfterConfirm"), () -> "modal did not close on confirm: " + report);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
index a0d9e1d34d..7a8c7e10b0 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
@@ -113,7 +113,7 @@ class PageDef_Contract_Test extends TestBase {
.tabs(Tab.create("releases", "Releases").view(view("releases")))
.build();
var json = Json.of(page);
- assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+ assertTrue(json.contains("\"contractVersion\":\"3\""), json);
assertTrue(json.contains("\"id\":\"admin\""), json);
assertTrue(json.contains("\"title\":\"Admin\""), json);
assertTrue(json.contains("\"tabs\":["), json);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java
new file mode 100644
index 0000000000..ddc9d2f2b5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTableContent_Test.java
@@ -0,0 +1,284 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420: emission tests for {@link Tab#content}/{@link Subtab#content} (raw, non-table panel bodies).
+ *
+ * <p>
+ * Covers the {@code PageTable.buildTabPanel} matrix ({@code Tab = {view} | {subtabs} | {content} |
+ * {content+subtabs}}, {@code Subtab = {view} | {content}}), the verbatim/unescaped {@code rawText(...)} emission
+ * contract (deliberately the opposite of every label/title, which stay HTML-escaped), and — the item's MED-1
+ * finding — that {@code content} is structurally unreachable from the PAGE_META sidecar because
+ * {@code PageTable.buildMeta} is a <b>projection</b> that never reads it, not because of a reserve-and-omit
+ * {@code @BeanType} trick (which is what actually closes it off {@code Json.of(...)}-wise, asserted separately
+ * below).
+ */
+class PageTableContent_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ /** Extracts the raw text between a sidecar's opening and closing {@code <script>} tags by element id. */
+ private static String sidecarBody(String html, String sidecarId) {
+ var open = html.indexOf("id=\"" + sidecarId + "\"");
+ assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ /** The single {@code <div ...>} start tag containing all of {@code required}; fails when not exactly one matches. */
+ private static String theDivTag(String html, String...required) {
+ var divs = new ArrayList<String>();
+ var i = 0;
+ while ((i = html.indexOf("<div", i)) >= 0) {
+ var end = html.indexOf('>', i);
+ divs.add(html.substring(i, end + 1));
+ i = end + 1;
+ }
+ var matches = divs.stream().filter(x -> Arrays.stream(required).allMatch(x::contains)).toList();
+ assertEquals(1, matches.size(),
+ () -> "expected exactly one <div> tag containing " + Arrays.toString(required) + ", found " + matches + " in:\n" + html);
+ return matches.get(0);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Leaf Tab = {content}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_leafTabContent_emittedVerbatimInsidePanel() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("overview", "Overview").content("<p>Hello, <b>world</b>.</p>"))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertTrue(html.contains("<p>Hello, <b>world</b>.</p>"),
+ () -> "raw content must appear byte-for-byte verbatim (no escaping):\n" + html);
+ }
+
+ @Test void a02_leafTabContentPanel_carriesOnlyPanelTabAttr() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("overview", "Overview").content("<p>Body.</p>"))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ var tag = theDivTag(html, PageTable.PANEL_CLASS, PageTable.PANEL_TAB_ATTR + "=\"overview\"");
+ assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR), tag);
+ }
+
+ @Test void a03_leafTabContent_noDataJuneauViewMarker() {
+ // A content-only tab references no ViewDef, so it must not emit a data-juneau-view table/marker at all.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("overview", "Overview").content("<p>Body.</p>"))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertFalse(html.contains("data-juneau-view"), html);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Preamble: Tab = {content+subtabs}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_tabPreambleContent_rendersAboveSubtabBar_inSameOuterPanel() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog")
+ .content("<p>Preamble prose.</p>")
+ .subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ var preambleIdx = html.indexOf("<p>Preamble prose.</p>");
+ var subtabBarIdx = html.indexOf(PageTable.SUBTAB_BAR_CLASS);
+ assertTrue(preambleIdx >= 0, html);
+ assertTrue(subtabBarIdx > preambleIdx,
+ () -> "preamble content must render ABOVE the sub-tab bar:\n" + html);
+ }
+
+ @Test void b02_tabPreambleContent_liveInsideTheSubtabAgnosticOuterPanel() {
+ // The outer panel wrapping the preamble + sub-tab bar must stay sub-tab-agnostic (no data-panel-subtab) -
+ // this is the existing PageTable_SubtabPanelContract_Test invariant, re-verified with a preamble present
+ // so the new code path can't have silently reintroduced it.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog")
+ .content("<p>Preamble.</p>")
+ .subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", PageTable.PANEL_TAB_ATTR + "=\"catalog\"");
+ assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR),
+ () -> "preamble must not force data-panel-subtab onto the outer panel: " + tag);
+ }
+
+ @Test void b03_tabWithoutPreamble_emitsNoExtraContentBeforeSubtabBar() {
+ // Regression: a tab with subtabs and NO content must render identically to before this item (no stray
+ // empty raw-text node ahead of the sub-tab bar).
+ var withoutPreamble = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var withPreamble = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog")
+ .subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ assertEquals(Html.of(PageTable.of(withoutPreamble)), Html.of(PageTable.of(withPreamble)));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Subtab = {content}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_subtabContent_emittedVerbatimInsideItsSubpanel() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("about", "About").content("<p>About text.</p>")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertTrue(html.contains("<p>About text.</p>"), html);
+ }
+
+ @Test void c02_subtabContentSubpanel_carriesBothTabAndSubtabAttrs() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("about", "About").content("<p>x</p>")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ var tag = theDivTag(html, PageTable.SUBPANEL_CLASS, PageTable.PANEL_SUBTAB_ATTR + "=\"about\"");
+ assertTrue(tag.contains(PageTable.PANEL_TAB_ATTR + "=\"catalog\""), tag);
+ }
+
+ @Test void c03_mixedViewAndContentSubtabsUnderOneTab_eachGetsItsOwnBody() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("data", "Data").view(view("data")),
+ Subtab.create("about", "About").content("<p>About.</p>")))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ assertTrue(html.contains("data-juneau-view=\"data\""), html);
+ assertTrue(html.contains("<p>About.</p>"), html);
+ // The content subtab must NOT also carry a data-juneau-view marker.
+ var aboutTag = theDivTag(html, PageTable.SUBPANEL_CLASS, PageTable.PANEL_SUBTAB_ATTR + "=\"about\"");
+ var aboutStart = html.indexOf(aboutTag);
+ var aboutEnd = html.indexOf(PageTable.SUBPANEL_CLASS, aboutStart + aboutTag.length());
+ var aboutSection = aboutEnd > 0 ? html.substring(aboutStart, aboutEnd) : html.substring(aboutStart);
+ assertFalse(aboutSection.contains("data-juneau-view"), aboutSection);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Ownership contract: verbatim / unescaped (opposite of every label/title)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_contentIsNeverHtmlEntityEscaped_unlikeLabels() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("t", "<script>alert('label')</script>")
+ .content("<script>alert('content')</script>"))
+ .build();
+ var html = Html.of(PageTable.of(page));
+ // The tab LABEL is escaped (existing contract, PageTable_Emit_Test b01) ...
+ assertFalse(html.contains("<script>alert('label')</script>"), html);
+ // ... but panel CONTENT is emitted verbatim - this is the template-engine ownership contract, not a bug.
+ assertTrue(html.contains("<script>alert('content')</script>"),
+ () -> "content must be emitted verbatim per the documented ownership contract:\n" + html);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // MED-1: PAGE_META is a projection - content is structurally unreachable, not merely omitted
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_contentBearingTab_producesIdenticalPageMetaToContentlessTab() {
+ // The core MED-1 assertion: buildMeta() copies id/label/subtabs only, so adding content to a Tab cannot
+ // change one single byte of PAGE_META. Proven by diffing the sidecar of two otherwise-identical pages.
+ var withoutContent = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var withContent = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog")
+ .content("<p>Ignored by PAGE_META.</p>")
+ .subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var metaWithout = sidecarBody(Html.of(PageTable.of(withoutContent)), "juneau-page:admin");
+ var metaWith = sidecarBody(Html.of(PageTable.of(withContent)), "juneau-page:admin");
+ assertEquals(metaWithout, metaWith,
+ () -> "PAGE_META must be byte-identical regardless of Tab#content - buildMeta() is a projection that "
+ + "never reads it, so it cannot leak in:\nwithout=" + metaWithout + "\nwith=" + metaWith);
+ }
+
+ @Test void e02_contentBearingSubtab_producesIdenticalPageMetaToViewBackedSubtab() {
+ // Same projection fact, exercised via a Subtab#content instead of Subtab#view - SubtabMeta pins id/label
+ // only, so swapping view<->content changes nothing in PAGE_META either.
+ var withView = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ var withContent = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>x</p>")))
+ .build();
+ var metaWithView = sidecarBody(Html.of(PageTable.of(withView)), "juneau-page:admin");
+ var metaWithContent = sidecarBody(Html.of(PageTable.of(withContent)), "juneau-page:admin");
+ assertEquals(metaWithView, metaWithContent);
+ }
+
+ @Test void e03_pageMetaNeverContainsTheLiteralStringContent_evenWhenPresentOnEveryTabAndSubtab() {
+ // Belt-and-suspenders: even with "content" set everywhere in the tree, the word never appears as a
+ // PAGE_META key. (Distinct from Json.of(Tab) reserve-and-omit, checked separately in f01/f02 below - this
+ // one is scoped to the actual PAGE_META sidecar the runtime consumes.)
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog")
+ .content("<p>preamble</p>")
+ .subtabs(Subtab.create("s", "S").content("<p>sub</p>")))
+ .build();
+ var meta = sidecarBody(Html.of(PageTable.of(page)), "juneau-page:admin");
+ assertFalse(meta.contains("content"), () -> "PAGE_META must never carry a 'content' key:\n" + meta);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Reserve-and-omit at the bean level: a direct Json.of(...) of the PageDef tree also omits content
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_directJsonOfPageDef_omitsTabContent() {
+ // Unlike PAGE_META (a projection - e01-e03), PageDef itself IS directly JSON-serializable
+ // (PageDef_Contract_Test exercises this same Json.of(page) path). Tab#content is deliberately absent from
+ // Tab's own @BeanType(properties=...) list, so it is reserved-and-omitted from THIS path too, at no
+ // CONTRACT_VERSION cost (the wire shape is byte-for-byte unchanged).
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "T").content("<p>trusted prose</p>"))
+ .build();
+ var json = Json.of(page);
+ assertFalse(json.contains("content"), () -> "Tab#content must be reserved-and-omitted from Json.of(PageDef): " + json);
+ assertTrue(json.contains("\"contractVersion\":\"" + PageDef.CONTRACT_VERSION + "\""), json);
+ }
+
+ @Test void f02_directJsonOfPageDef_omitsSubtabContent() {
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>trusted prose</p>")))
+ .build();
+ var json = Json.of(page);
+ assertFalse(json.contains("content"), () -> "Subtab#content must be reserved-and-omitted from Json.of(PageDef): " + json);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
new file mode 100644
index 0000000000..6cd1163be2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
@@ -0,0 +1,304 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+
+/**
+ * Test-only, <b>private</b> source scanner enforcing the TODO-420 BLK-1 ownership contract — "template
+ * engine, trusted / first-party content only" — on {@link Tab#content}/{@link Subtab#content}.
+ *
+ * <p>
+ * Not shipped and deliberately not public API, mirroring the discipline of the {@code ScriptJsonSinkScanner}
+ * (juneau-commons) and {@code ChromeCssScanner} (juneau-rest-server-console-ui) test-only guards this class is
+ * modeled on. It scans this module's own Java sources (both {@code src/main/java} and {@code src/test/java}, since
+ * unlike the {@code <script>}-JSON sink this is a brand-new, forward-looking guardrail with no pre-existing
+ * "known-good" callers to anchor a main-source-only scan to — see the accompanying test's anti-vacuous
+ * checks) and fails the build if any call to the {@link Tab#content(String)}/{@link Subtab#content(String)}
+ * fluent-setter passes an argument that is not a compile-time string literal (or a {@code +}-concatenation of
+ * literals).
+ *
+ * <h5 class='section'>Why "is the argument a literal" is the enforcement mechanism</h5>
+ * <p>
+ * A build-time scanner cannot do taint analysis, so it cannot directly ask "does this value ever carry
+ * live/remote/attacker-influenceable data". A compile-time string literal provably cannot: it is baked into the
+ * {@code .class} file, reviewed the same way any other source line is, and by construction carries no
+ * request/response/remote-call data. Conversely, <i>any</i> non-literal argument — an identifier, a field
+ * read, a method call, string concatenation involving a non-literal — is exactly the shape a write-path
+ * confirmation/detail emitter would use to build a body from live data (an incident title, a Slack thread, a
+ * request parameter). Requiring literal-only arguments is therefore the strongest checkable proxy for the
+ * "trusted / first-party content only" contract documented on {@link Tab#content}/{@link Subtab#content}: it lets
+ * through exactly the FG-2 docs-prose use case (hand-authored markup baked into the source) and flags everything
+ * a live-data confirmation body would look like.
+ *
+ * <h5 class='section'>What counts as a sink</h5>
+ * <p>
+ * A sink is any source-level call whose method name is exactly {@code content} (i.e. {@code .content(...)}) —
+ * scoped to this module's own tree, where (verified before this scanner was written) the only such calls are the
+ * {@link Tab}/{@link Subtab} fluent setters this item adds; nothing else in this module declares or calls a
+ * {@code content(...)} method. This scanner does not attempt receiver-type resolution and does not scan outside
+ * this module (other Juneau modules declare unrelated {@code content(...)} methods, e.g.
+ * {@code RestRequest.content(Object)}, which are out of scope by construction because they are never on this
+ * module's classpath of scanned files).
+ *
+ * <h5 class='section'>Why it must also assert it is not vacuous</h5>
+ * <p>
+ * A scanner that silently stops matching reads as a passing test, which is worse than no scanner. Comments are
+ * therefore stripped before analysis (a mention of {@code .content(} inside a comment must not be flagged, and
+ * must not be mistaken for a real call either), and the accompanying test asserts the live scan of this module's
+ * own tree still finds real, known-good literal sinks (the ones this item's own test fixtures add) and that
+ * mutating a known-good literal call into a non-literal one turns it into a violation.
+ */
+final class RawContentSinkScanner {
+
+ private RawContentSinkScanner() {}
+
+ /** One {@code .content(...)} call site found in a source. */
+ record Sink(String file, boolean literalOnly, String detail) {}
+
+ /** The outcome of a scan: every sink found, and the subset that are violations. */
+ record Result(List<Sink> sinks, List<String> violations) {}
+
+ /** Matches a {@code .content(} call (the {@code Tab}/{@code Subtab} raw-content fluent setter). */
+ private static final Pattern CONTENT_CALL = Pattern.compile("\\.content\\s*\\(");
+
+ /** A single string literal or text-block literal (content kept verbatim by {@link #stripComments}). */
+ private static final Pattern LITERAL_TOKEN =
+ Pattern.compile("\\s*(?:\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|null)\\s*");
+
+ /**
+ * Excluded, by simple filename, from {@link #scanTree(Path)}: this is the accompanying security-test file,
+ * which deliberately embeds synthetic RED (and GREEN) fixtures as source-text string/text-block literals
+ * (e.g. a fixture literally containing {@code "Tab.create(...).content(someIdentifier)"} as example text).
+ * Because {@link #stripComments} keeps string content verbatim (needed so real call arguments stay
+ * inspectable - see this class's javadoc), tree-scanning that file would find its RED-fixture example text and
+ * flag it as a false-positive violation purely because the file holding the example is itself in the scanned
+ * tree. This is the only file in this module whose job is to hold such text as <i>data</i> rather than as a
+ * real call, so excluding it by name is narrow and safe; every other test file in this tree using
+ * {@code .content(...)} is a real call this scanner is meant to see (and lean on for anti-vacuousness).
+ */
+ private static final String EXCLUDED_FIXTURE_FILE = "RawContentSink_SecurityScan_Test.java";
+
+ /**
+ * Scans a single Java source string.
+ *
+ * @param file A label for the source (used in violation messages).
+ * @param source The Java source text.
+ * @return The sinks found and any violations.
+ */
+ static Result scan(String file, String source) {
+ var sinks = new ArrayList<Sink>();
+ var violations = new ArrayList<String>();
+ var code = stripComments(source);
+
+ var m = CONTENT_CALL.matcher(code);
+ while (m.find()) {
+ var openParen = m.end() - 1;
+ var arg = balancedArg(code, openParen).trim();
+ var literalOnly = isLiteralOnly(arg);
+ sinks.add(new Sink(file, literalOnly, "content(" + arg + ")"));
+ if (!literalOnly)
+ violations.add(file + ": content(...) argument is not a compile-time literal - flagged as a "
+ + "possible live-data write-path confirmation/detail body reaching the raw-markup sink: "
+ + "content(" + arg + ")");
+ }
+ return new Result(sinks, violations);
+ }
+
+ /** Convenience overload for ad-hoc source strings. */
+ static Result scan(String source) {
+ return scan("(source)", source);
+ }
+
+ /**
+ * Walks the specified module root and scans every {@code src/main/java} and {@code src/test/java} Java
+ * source under it (both trees, per this class's javadoc: this guardrail has no pre-existing main-source-only
+ * "known-good" anchor to lean on the way {@code ScriptJsonSinkScanner} does).
+ *
+ * @param moduleRoot The module root (e.g. {@code juneau-rest/juneau-rest-server-views}).
+ * @return The aggregated sinks and violations across the tree.
+ * @throws IOException If the tree cannot be walked.
+ */
+ static Result scanTree(Path moduleRoot) throws IOException {
+ var sinks = new ArrayList<Sink>();
+ var violations = new ArrayList<String>();
+ var target = File.separator + "target" + File.separator;
+ for (var sourceDir : List.of("main", "test")) {
+ var srcRoot = moduleRoot.resolve("src").resolve(sourceDir).resolve("java");
+ if (!Files.isDirectory(srcRoot))
+ continue;
+ try (var stream = Files.walk(srcRoot)) {
+ var files = stream
+ .filter(p -> p.toString().endsWith(".java"))
+ .filter(p -> !p.toString().contains(target))
+ .filter(p -> !p.getFileName().toString().equals(EXCLUDED_FIXTURE_FILE))
+ .sorted()
+ .toList();
+ for (var f : files) {
+ var r = scan(moduleRoot.relativize(f).toString(), Files.readString(f));
+ sinks.addAll(r.sinks());
+ violations.addAll(r.violations());
+ }
+ }
+ }
+ return new Result(sinks, violations);
+ }
+
+ /**
+ * Locates the {@code juneau-rest-server-views} module root by walking up from the working directory until a
+ * directory holding both {@code juneau-core} and {@code juneau-rest} is found, then resolving into it.
+ *
+ * @return The module root, or {@code null} if not found.
+ */
+ static Path locateModuleRoot() {
+ for (var d = Path.of("").toAbsolutePath(); d != null; d = d.getParent()) {
+ if (Files.isDirectory(d.resolve("juneau-core")) && Files.isDirectory(d.resolve("juneau-rest"))) {
+ var module = d.resolve("juneau-rest").resolve("juneau-rest-server-views");
+ return Files.isDirectory(module) ? module : null;
+ }
+ }
+ return null;
+ }
+
+ /** True if {@code arg} is composed entirely of string/text-block literals (and/or {@code null}), optionally {@code +}-joined. */
+ private static boolean isLiteralOnly(String arg) {
+ var s = arg;
+ if (s.isBlank())
+ return true; // a no-arg / blank call carries nothing - trivially not a data leak
+ var i = 0;
+ while (i < s.length()) {
+ var m = LITERAL_TOKEN.matcher(s.substring(i));
+ if (!m.lookingAt())
+ return false;
+ i += m.end();
+ if (i >= s.length())
+ return true;
+ if (s.charAt(i) != '+')
+ return false;
+ i++;
+ }
+ return true;
+ }
+
+ /** Returns the (comma-and-nesting-preserving) argument text between the balanced parens starting at {@code openParen}. */
+ private static String balancedArg(String s, int openParen) {
+ var depth = 0;
+ var sb = new StringBuilder();
+ for (var i = openParen; i < s.length(); i++) {
+ var c = s.charAt(i);
+ if (c == '(') {
+ depth++;
+ if (depth == 1)
+ continue;
+ } else if (c == ')') {
+ depth--;
+ if (depth == 0)
+ break;
+ }
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Blanks out comment content (keeping string/char literals fully intact, including their delimiters and
+ * inner text) so that (a) a {@code .content(} mention inside a comment cannot be mistaken for a real call, and
+ * (b) the real literal text of a string argument stays inspectable by {@link #isLiteralOnly(String)}. This is
+ * the one deliberate divergence from {@code ScriptJsonSinkScanner}'s stripper, which blanks string content too
+ * - that scanner never needs to look inside a literal, this one always does.
+ */
+ private static String stripComments(String s) {
+ var out = new StringBuilder(s.length());
+ var n = s.length();
+ var i = 0;
+ while (i < n) {
+ var c = s.charAt(i);
+ if (c == '/' && i + 1 < n && s.charAt(i + 1) == '/') {
+ while (i < n && s.charAt(i) != '\n')
+ i++;
+ continue;
+ }
+ if (c == '/' && i + 1 < n && s.charAt(i + 1) == '*') {
+ i += 2;
+ while (i + 1 < n && !(s.charAt(i) == '*' && s.charAt(i + 1) == '/')) {
+ if (s.charAt(i) == '\n')
+ out.append('\n');
+ i++;
+ }
+ i = Math.min(n, i + 2);
+ continue;
+ }
+ if (c == '"' && i + 2 < n && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"') {
+ out.append("\"\"\"");
+ i += 3;
+ while (i + 2 < n && !(s.charAt(i) == '"' && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"')) {
+ out.append(s.charAt(i));
+ i++;
+ }
+ if (i + 2 < n) {
+ out.append("\"\"\"");
+ i += 3;
+ } else {
+ i = n;
+ }
+ continue;
+ }
+ if (c == '"') {
+ out.append('"');
+ i++;
+ while (i < n && s.charAt(i) != '"') {
+ if (s.charAt(i) == '\\' && i + 1 < n) {
+ out.append(s.charAt(i)).append(s.charAt(i + 1));
+ i += 2;
+ continue;
+ }
+ out.append(s.charAt(i));
+ i++;
+ }
+ if (i < n) {
+ out.append('"');
+ i++;
+ }
+ continue;
+ }
+ if (c == '\'') {
+ out.append('\'');
+ i++;
+ while (i < n && s.charAt(i) != '\'') {
+ if (s.charAt(i) == '\\' && i + 1 < n) {
+ out.append(s.charAt(i)).append(s.charAt(i + 1));
+ i += 2;
+ continue;
+ }
+ out.append(s.charAt(i));
+ i++;
+ }
+ if (i < n) {
+ out.append('\'');
+ i++;
+ }
+ continue;
+ }
+ out.append(c);
+ i++;
+ }
+ return out.toString();
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
new file mode 100644
index 0000000000..867f18a405
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420 BLK-1 gate: no source in this module may pour a non-literal (i.e. plausibly live-data-derived) value
+ * into {@link Tab#content}/{@link Subtab#content} — the raw-markup panel-body sink.
+ *
+ * <p>
+ * This is the "guardrail with teeth" the item's cross-family adversarial review required before the raw-markup
+ * shape could go READY: {@link Tab#content}/{@link Subtab#content} are a documented "template engine, trusted /
+ * first-party content only" sink (caller sanitizes, framework emits verbatim - see their javadoc). A write-path
+ * confirmation/detail body built from live data (an incident title, a Slack thread, a request parameter) must
+ * never reach it. Because this framework's own sources have no such confirmation emitters yet, this gate is
+ * necessarily forward-looking: it is proven correct here by exhaustive RED/GREEN unit checks on the scanner
+ * mechanism itself (section a), then pointed at this module's real tree (section b) to lock in that today's
+ * sources - main <i>and</i> test - are clean, and to prove the live scan is not accidentally vacuous.
+ */
+class RawContentSink_SecurityScan_Test extends TestBase {
+
+ // -----------------------------------------------------------------------------------------------------------
+ // a: Unit RED/GREEN on the scanner mechanism itself
+ // -----------------------------------------------------------------------------------------------------------
+
+ /** RED: a bare identifier argument - the classic "pour a variable into content" shape - is a violation. */
+ @Test void a01_identifierArgument_isFlagged() {
+ var src = """
+ class X {
+ void f(String incidentTitle) {
+ Tab.create("t", "T").content(incidentTitle);
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ assertFalse(r.sinks().get(0).literalOnly());
+ }
+
+ /** RED: a method-call argument - the shape a confirmation/detail body renderer would use - is a violation. */
+ @Test void a02_methodCallArgument_isFlagged() {
+ var src = """
+ class X {
+ void f(Object gack) {
+ Subtab.create("s", "S").content(renderConfirmationHtml(gack));
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ }
+
+ /** RED: concatenating a literal with a non-literal is still a violation - the literal prefix/suffix doesn't launder it. */
+ @Test void a03_concatenationWithNonLiteral_isFlagged() {
+ var src = """
+ class X {
+ void f(String title) {
+ Tab.create("t", "T").content("<p>" + title + "</p>");
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ }
+
+ /** GREEN: a single string literal - the FG-2 docs-prose shape - passes. */
+ @Test void a04_singleStringLiteral_passes() {
+ var src = """
+ class X {
+ void f() {
+ Tab.create("t", "T").content("<p>Hello, world.</p>");
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(List.of(), r.violations());
+ assertTrue(r.sinks().get(0).literalOnly());
+ }
+
+ /** GREEN: a text-block literal passes (built via string concatenation here to avoid nested triple-quote escaping in this fixture). */
+ @Test void a05_textBlockLiteral_passes() {
+ var src = "class X {\n"
+ + " void f() {\n"
+ + " Tab.create(\"t\", \"T\").content(\"\"\"\n"
+ + " <p>Hello</p>\n"
+ + " \"\"\");\n"
+ + " }\n"
+ + "}\n";
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(List.of(), r.violations());
+ assertTrue(r.sinks().get(0).literalOnly());
+ }
+
+ /** GREEN: concatenating two-or-more literals together is still all-literal, so it passes. */
+ @Test void a06_concatenationOfOnlyLiterals_passes() {
+ var src = """
+ class X {
+ void f() {
+ Tab.create("t", "T").content("<p>" + "Hello" + "</p>");
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(List.of(), r.violations());
+ }
+
+ /**
+ * A {@code .content(} mention that lives only in a comment must NOT be mistaken for a real call - this is the
+ * exact silent-decay failure mode a scanner exists to prevent, so it is asserted directly (mirrors
+ * {@code ScriptJsonSinkScanner}'s equivalent check).
+ */
+ @Test void a07_commentMentionOfContentCall_isNotASink() {
+ var src = """
+ class X {
+ // old code used to do: Tab.create("t", "T").content(dynamicTitle);
+ void f() {
+ Tab.create("t", "T").content("<p>Hello.</p>");
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(List.of(), r.violations());
+ }
+
+ /** {@code null} is treated as a safe (non-data-carrying) argument, not a violation. */
+ @Test void a08_nullArgument_isNotFlagged() {
+ var src = """
+ class X {
+ void f() {
+ Subtab.create("s", "S").content(null);
+ }
+ }
+ """;
+ var r = RawContentSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(List.of(), r.violations());
+ }
+
+ /** Mutation check on a synthetic fixture: a GREEN literal call, once mutated to a non-literal, is flagged. */
+ @Test void a09_mutatingAGreenLiteralIntoAnIdentifier_turnsItIntoAViolation() {
+ var clean = "class X { void f() { Tab.create(\"t\", \"T\").content(\"<p>Hello.</p>\"); } }";
+ var cleanResult = RawContentSinkScanner.scan(clean);
+ assertEquals(List.of(), cleanResult.violations());
+
+ var mutated = clean.replace("\"<p>Hello.</p>\"", "buildLiveConfirmationBody(incident)");
+ var mutatedResult = RawContentSinkScanner.scan(mutated);
+ assertEquals(1, mutatedResult.violations().size(),
+ () -> "mutating a literal content(...) argument into a method call must be flagged: " + mutatedResult.violations());
+ }
+
+ // -----------------------------------------------------------------------------------------------------------
+ // b: Live gate against this module's real tree (main AND test sources)
+ // -----------------------------------------------------------------------------------------------------------
+
+ private static Path requireModuleRoot() {
+ var root = RawContentSinkScanner.locateModuleRoot();
+ assertNotNull(root, "could not locate the juneau-rest-server-views module root from the working "
+ + "directory - the security gate must fail loudly rather than pass vacuously when it cannot find the "
+ + "sources it guards");
+ return root;
+ }
+
+ /** GREEN: this module's real tree - main and test sources alike - has no content(...) sink fed by a non-literal. */
+ @Test void b01_realModuleTree_hasNoViolations() throws Exception {
+ var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+ assertEquals(List.of(), r.violations(),
+ () -> "non-literal content(...) sink(s) found - a write-path confirmation/detail emitter may have "
+ + "reached the raw-markup panel-content sink:\n " + String.join("\n ", r.violations()));
+ }
+
+ /**
+ * Anti-vacuous: the live scan must actually be finding real content(...) call sites (this item's own
+ * legitimate literal usages in {@code TabSubtabContent_Validation_Test}/{@code PageTableContent_Test}). If
+ * this ever drops to zero, the "no violations" result above is meaningless - the scanner would have quietly
+ * stopped matching, or every real usage would have been (wrongly) excluded from the tree walk.
+ */
+ @Test void b02_realModuleTree_findsKnownGoodSinks_notVacuous() throws Exception {
+ var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+ assertTrue(r.sinks().size() >= 10, () -> "expected >=10 real content(...) call sites, found: " + r.sinks());
+ assertTrue(r.sinks().stream().allMatch(RawContentSinkScanner.Sink::literalOnly),
+ () -> "expected every real sink found today to be literal-only: " + r.sinks());
+ }
+
+ /**
+ * Mutation check against a real file in the tree: a known-good literal {@code content(...)} call in
+ * {@code TabSubtabContent_Validation_Test} passes as-is, but mutating that exact literal into a plausible
+ * live-data expression turns it into a violation - proving the guard genuinely exercises real files rather
+ * than passing them by accident.
+ */
+ @Test void b03_realKnownGoodCall_passesButFailsWhenMutatedToNonLiteral() throws Exception {
+ var root = requireModuleRoot();
+ var file = "src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java";
+ var source = Files.readString(root.resolve(file));
+ var knownGoodCall = "Tab.create(\"overview\", \"Overview\").content(\"<p>Hello, world.</p>\")";
+ assertTrue(source.contains(knownGoodCall), () -> file + " no longer contains the expected known-good call "
+ + "- update this test's fixture string to match the current source");
+
+ var clean = RawContentSinkScanner.scan(file, source);
+ assertEquals(List.of(), clean.violations());
+
+ var mutated = source.replace(knownGoodCall,
+ "Tab.create(\"overview\", \"Overview\").content(buildLiveConfirmationBody(incident))");
+ var mutatedResult = RawContentSinkScanner.scan(file, mutated);
+ assertTrue(mutatedResult.violations().size() >= 1,
+ () -> "mutating the known-good call into a method-call argument must be flagged: " + mutatedResult.violations());
+ }
+
+ /** The scanner's own accompanying fixture file is excluded from the tree walk - sanity-check that exclusion. */
+ @Test void b04_scannersOwnFixtureFile_isExcludedFromTheTreeWalk() throws Exception {
+ var r = RawContentSinkScanner.scanTree(requireModuleRoot());
+ assertTrue(r.sinks().stream().noneMatch(s -> s.file().endsWith("RawContentSink_SecurityScan_Test.java")),
+ () -> "the scanner's own RED/GREEN fixture file must be excluded from the tree walk (its example "
+ + "strings would otherwise register as false-positive sinks): " + r.sinks());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
new file mode 100644
index 0000000000..fd2174cd91
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.filter.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The row-action half of the module's <b>JavaScript-execution harness</b>: runs the REAL served
+ * {@code juneau-views.js} in a real headless browser and asserts the fail-closed CSRF submit behaves as a user
+ * would experience it — a blank/absent/whitespace token issues NO request and renders a VISIBLE refusal, and
+ * a valid token issues a JSON {@code POST} carrying the {@code X-Csrf-Token} header (TODO-415).
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_RowActions_Test} proves the shipped script <i>contains</i> the fail-closed logic; it cannot prove
+ * the logic actually suppresses a request and surfaces a visible refusal at runtime. This canary drives the real
+ * runtime in Chromium against a stubbed {@code fetch}, so "the request was not sent and a banner became visible" is
+ * measured rather than inferred. The client refusal is defense-against-consumer-omission; the server-side
+ * {@link LoopbackBoundary} is the real control.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. It reuses that profile's provisioned Node + Playwright browser, and derives its own prober
+ * ({@code row-actions.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is needed
+ * to add this second canary.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link PagePanelVisibility_BrowserTest} — the sibling {@code juneau-pages.js} canary.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=RowActionCsrf_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowActionCsrf_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ // The pom's js-tests profile provisions ONE harness property (the panel-visibility prober); this second
+ // canary lives beside it in src/test/js, so it is derived from that property's directory rather than adding
+ // a new pom property.
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("row-actions.cjs");
+
+ // The fixture restates nothing under test: it loads the REAL served juneau-views.js. No jQuery/DataTables
+ // is needed - the prober drives the runtime's exposed pure + DOM helpers directly.
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("row-actions.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("row-actions-stdout.json");
+ var stderr = dir.resolve("row-actions-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> req(String key) {
+ return (Map<String,Object>) report.get(key);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The runtime loaded, at contract version "3"
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_runtimeLoadedAtContractVersionThree() {
+ assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+ assertEquals("3", report.get("contractVersion"), () -> report.toString());
+ assertEquals(LoopbackBoundary.DEFAULT_CSRF_HEADER, report.get("defaultCsrfHeader"), () -> report.toString());
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) The pure request builder fails closed
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_whitespaceTokenRefuses() {
+ assertEquals(Boolean.TRUE, req("reqBlankWhitespace").get("refuse"), () -> report.toString());
+ assertEquals("missing-token", req("reqBlankWhitespace").get("reason"), () -> report.toString());
+ }
+
+ @Test void b02_absentTokenRefuses() {
+ assertEquals(Boolean.TRUE, req("reqAbsent").get("refuse"), () -> report.toString());
+ assertEquals("missing-token", req("reqAbsent").get("reason"), () -> report.toString());
+ }
+
+ @Test void b03_safeMethodRefuses() {
+ assertEquals(Boolean.TRUE, req("reqSafeMethod").get("refuse"), () -> report.toString());
+ assertEquals("safe-method", req("reqSafeMethod").get("reason"), () -> report.toString());
+ }
+
+ @Test void b04_validTokenYieldsJsonRequestWithCsrfHeader() {
+ var r = req("reqValid");
+ assertNull(r.get("refuse"), () -> report.toString());
+ assertEquals("POST", r.get("method"), () -> report.toString());
+ @SuppressWarnings("unchecked")
+ var headers = (Map<String,Object>) r.get("headers");
+ assertEquals("application/json", headers.get("Content-Type"), () -> report.toString());
+ assertEquals("tok-123", headers.get(LoopbackBoundary.DEFAULT_CSRF_HEADER), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) The DOM submit path: blank token = visible refusal + NO request; valid token = a real request, no banner
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_blankTokenIssuesNoRequestAndShowsVisibleRefusal() {
+ assertEquals(Boolean.FALSE, report.get("blankTokenFetchIssued"), () -> "a request was issued with no token: " + report);
+ assertEquals(Boolean.TRUE, report.get("blankTokenRefusalVisible"), () -> "no VISIBLE refusal rendered: " + report);
+ assertNotNull(report.get("blankTokenRefusalText"), () -> report.toString());
+ }
+
+ @Test void c02_validTokenIssuesTheRequestWithNoRefusal() {
+ assertEquals(Boolean.TRUE, report.get("validTokenFetchIssued"), () -> "no request was issued with a valid token: " + report);
+ assertEquals("POST", report.get("validTokenMethod"), () -> report.toString());
+ assertEquals("application/json", report.get("validTokenContentType"), () -> report.toString());
+ assertEquals("tok-123", report.get("validTokenCsrfHeader"), () -> report.toString());
+ assertEquals(Boolean.FALSE, report.get("validTokenRefusalVisible"), () -> "a refusal rendered for a valid submit: " + report);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java
new file mode 100644
index 0000000000..b15a9256da
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowSelectionBulk_BrowserTest.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The row-selection + bulk-mutation half of the module's <b>JavaScript-execution harness</b> ({@code TODO-428}):
+ * runs the REAL served {@code juneau-views.js} in a real headless browser and asserts, as a user would experience
+ * it, that:
+ * <ul>
+ * <li>per-row selection and select-all toggle a live selection set keyed by the STABLE row id (never a DOM index);
+ * <li>a poll/sort/page draw silently DROPS any selected id no longer on screen (MED-11's persistence rule);
+ * <li>row-selection and bulk-mutation are two INDEPENDENT opt-ins - a selection-only (e.g. export) table never
+ * carries the bulk marker, even though both are declared via the same {@code hasSelection}/{@code hasBulk}
+ * DOM-attribute mechanism (HIGH-5);
+ * <li>a bulk action fires N INDEPENDENT per-row writes - never one aggregate request - each with its OWN
+ * in-flight marker and its OWN typed {@code ActionResult}, so one target's failure/refusal can never be
+ * hidden behind an overall "success" (MED-4);
+ * <li>a selected id whose row is no longer present (went off-screen between the click and execution) is silently
+ * skipped rather than targeted;
+ * <li>the bulk toolbar's buttons are live-gated on the selection count, and the independently-versioned bulk
+ * sidecar is read/contract-checked correctly at runtime (R2).
+ * </ul>
+ *
+ * <h5 class='section'>Why this exists (beyond the always-on source-shape test):</h5>
+ * <p>
+ * {@link ViewsJs_Selection_Test} proves the shipped script <i>contains</i> the selection/bulk logic in the right
+ * shape; it cannot prove a checkbox click actually updates the live selection set, that a draw actually prunes an
+ * off-screen id, or that a bulk action actually issues N independent requests with N independent settled outcomes.
+ * This canary drives the real runtime in Chromium against a stubbed {@code fetch}, so those user-visible facts are
+ * measured rather than inferred.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does. It reuses that profile's provisioned Node + Playwright browser and derives its own prober
+ * ({@code row-selection-bulk.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom change is
+ * needed.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ModalResult_BrowserTest} — the sibling TODO-416/417 canary this reuses the settle
+ * path from.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=RowSelectionBulk_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowSelectionBulk_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("row-selection-bulk.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("row-selection-bulk.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("row-selection-bulk-stdout.json");
+ var stderr = dir.resolve("row-selection-bulk-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> sub(String key) {
+ return (Map<String,Object>) report.get(key);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The runtime loaded, at the bulk contract's OWN version (independent of VIEW_META's)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_runtimeLoadedAtBulkContractVersion() {
+ assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
+ assertEquals(BulkMutateDef.CONTRACT_VERSION, report.get("bulkContractVersion"), () -> report.toString());
+ assertEquals("1", BulkMutateDef.CONTRACT_VERSION);
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Per-row selection + select-all, keyed by the stable row id
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_checkingTwoRowsSelectsExactlyThoseStableIds() {
+ assertEquals(List.of("1", "2"), report.get("afterTwoChecked"), () -> report.toString());
+ }
+
+ @Test void b02_selectAllSelectsEveryRowCurrentlyOnScreen() {
+ assertEquals(Boolean.TRUE, report.get("hasSelectAllCheckbox"), () -> report.toString());
+ assertEquals(List.of("1", "2", "3"), report.get("afterSelectAll"), () -> report.toString());
+ }
+
+ @Test void b03_deselectAllClearsTheSelection() {
+ assertEquals(List.of(), report.get("afterDeselectAll"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) The off-screen-id-drop persistence rule (MED-11/Q2) actually prunes on a draw
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_aDrawDropsAnIdThatLeftTheScreen() {
+ // Row '3' was removed from the DOM (simulating it leaving the current page/sort/poll draw) before the
+ // draw.dt tick fired - the persistence rule must drop it, keeping only '1' and '2'.
+ assertEquals(List.of("1", "2"), report.get("selectedAfterOffScreenDraw"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Two INDEPENDENT opt-ins (HIGH-5) - verified against the ACTUAL DOM-attribute detection at runtime
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_selectionOnlyTableNeverCarriesTheBulkMarker() {
+ assertEquals(Boolean.TRUE, report.get("selectOnlyHasSelection"), () -> report.toString());
+ assertEquals(Boolean.FALSE, report.get("selectOnlyHasBulk"),
+ () -> "a selection-only (export) table must never surface a bulk-mutate control: " + report);
+ }
+
+ @Test void d02_bulkTableCarriesBothMarkers() {
+ assertEquals(Boolean.TRUE, report.get("withBulkHasSelection"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("withBulkHasBulk"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Bulk = N INDEPENDENT per-row writes; per-target typed result; per-row in-flight marker; off-screen skip
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_bulkIssuesExactlyOneRequestPerSelectedOnScreenTarget() {
+ var bulk = sub("bulk");
+ // Four ids were "selected" but only three ('1','2','3') have an on-screen row; '9' must be silently
+ // skipped rather than targeted - never a fourth, aggregate, or missing-target request.
+ assertEquals(3L, ((Number) bulk.get("fetchCount")).longValue(), () -> report.toString());
+ assertEquals(List.of("1", "2", "3"), bulk.get("targetIds"), () -> report.toString());
+ assertEquals(List.of("ack", "ack", "ack"), bulk.get("actionIds"), () -> report.toString());
+ }
+
+ @Test void e02_eachTargetWasMarkedInFlightBeforeItsOwnRequest() {
+ var bulk = sub("bulk");
+ @SuppressWarnings("unchecked")
+ var atFetch = (Map<String,Object>) bulk.get("inflightAtFetchTime");
+ assertEquals(Boolean.TRUE, atFetch.get("1"), () -> report.toString());
+ assertEquals(Boolean.TRUE, atFetch.get("2"), () -> report.toString());
+ assertEquals(Boolean.TRUE, atFetch.get("3"), () -> report.toString());
+ }
+
+ @Test void e03_everyTargetsInFlightMarkerClearedOnItsOwnTerminalOutcome() {
+ // MED-4: per-target marker clearing on every terminal outcome - success, failure, AND refusal.
+ var bulk = sub("bulk");
+ @SuppressWarnings("unchecked")
+ var after = (Map<String,Object>) bulk.get("inflightAfter");
+ assertEquals(Boolean.FALSE, after.get("1"), () -> report.toString());
+ assertEquals(Boolean.FALSE, after.get("2"), () -> report.toString());
+ assertEquals(Boolean.FALSE, after.get("3"), () -> report.toString());
+ }
+
+ @Test void e04_perTargetResultsAreIndependent_noAggregateSuccessMasksAFailure() {
+ // The load-bearing HIGH-5/MED-4 case: target '1' succeeds, '2' fails, '3' is refused - all three render
+ // their OWN outcome; none is hidden behind (or overwritten by) another target's result.
+ var bulk = sub("bulk");
+ @SuppressWarnings("unchecked")
+ var o1 = (Map<String,Object>) bulk.get("outcome1");
+ @SuppressWarnings("unchecked")
+ var o2 = (Map<String,Object>) bulk.get("outcome2");
+ @SuppressWarnings("unchecked")
+ var o3 = (Map<String,Object>) bulk.get("outcome3");
+ assertEquals("success", o1.get("state"), () -> report.toString());
+ assertTrue(String.valueOf(o1.get("text")).contains("Done"), () -> report.toString());
+ assertEquals("failure", o2.get("state"), () -> report.toString());
+ assertTrue(String.valueOf(o2.get("text")).contains("nope"), () -> report.toString());
+ assertEquals("refusal", o3.get("state"), () -> report.toString());
+ assertTrue(String.valueOf(o3.get("text")).contains("write-guard:not-armed"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) The bulk toolbar live-gates its buttons on the selection count
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_toolbarButtonsAreDisabledUntilSomethingIsSelected() {
+ var toolbar = sub("toolbar");
+ assertEquals(Boolean.TRUE, toolbar.get("disabledInitially"), () -> report.toString());
+ assertEquals(Boolean.FALSE, toolbar.get("disabledWithSelection"), () -> report.toString());
+ assertTrue(String.valueOf(toolbar.get("countTextWithSelection")).contains("2"), () -> report.toString());
+ assertEquals(Boolean.TRUE, toolbar.get("disabledAfterCleared"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) The independently-versioned bulk sidecar is actually read + contract-checked at runtime (R2)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_bulkSidecarIsReadAndContractChecked() {
+ var sidecar = sub("bulkSidecar");
+ assertEquals(BulkMutateDef.CONTRACT_VERSION, sidecar.get("contractVersion"), () -> report.toString());
+ assertEquals(1L, ((Number) sidecar.get("actionCount")).longValue(), () -> report.toString());
+ assertEquals(Boolean.TRUE, sidecar.get("missingSidecarReturnsNull"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java
new file mode 100644
index 0000000000..764b921196
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TabSubtabContent_Validation_Test.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-420: validation-matrix tests for {@link Tab#content}/{@link Subtab#content}.
+ *
+ * <p>
+ * Pins the panel-body matrices stated on {@link Tab} and {@link Subtab}'s javadoc and mirrored across all three
+ * enforcement sites (design doc §"Findings resolution" MED-2):
+ * <ul class='spaced-list'>
+ * <li><b>{@code Tab = {view} | {subtabs} | {content} | {content+subtabs}}</b> — exactly one of
+ * {@code view}/{@code subtabs}/{@code content}, EXCEPT {@code content} may co-occur with {@code subtabs}
+ * (the preamble case).
+ * <li><b>{@code Subtab = {view} | {content}}</b> — exactly one.
+ * </ul>
+ *
+ * <p>
+ * Also covers the {@link PageDef#build()} consequence of widening the matrix: a content-only tab (no
+ * {@code view}, no {@code subtabs}) and a subtab carrying {@code content} instead of {@code view} must not throw
+ * (the pre-420 {@code PageDef.validate()} unconditionally dereferenced {@code t.subtabs}/{@code s.view} and would
+ * NPE on either shape).
+ *
+ * <p>
+ * Deliberately kept in its own 420-owned file (not added to the pre-existing {@code PageDef_Validation_Test}), per
+ * the item's constraint to keep every new assertion in a 420-owned fixture.
+ */
+class TabSubtabContent_Validation_Test extends TestBase {
+
+ public static class Release {
+ public String name;
+ }
+
+ private static ViewDef view(String id) {
+ return ViewDef.create(id)
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/" + id + "/data")
+ .columns(Column.of("name").title("Name"))
+ .build();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Tab matrix: {view} | {subtabs} | {content} | {content+subtabs}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_tabWithContentOnly_accepted() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("overview", "Overview").content("<p>Hello, world.</p>"))
+ .build();
+ assertEquals("<p>Hello, world.</p>", page.tabs.get(0).content);
+ }
+
+ @Test void a02_tabWithContentAndSubtabs_accepted() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog")
+ .content("<p>Preamble.</p>")
+ .subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ assertEquals("<p>Preamble.</p>", page.tabs.get(0).content);
+ assertEquals(1, page.tabs.get(0).subtabs.size());
+ }
+
+ @Test void a03_tabWithViewAndContent_rejected() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("t", "T").view(view("v")).content("<p>x</p>"));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void a04_tabWithViewAndSubtabsAndContent_rejected() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("t", "T").view(view("v")).content("<p>x</p>")
+ .subtabs(Subtab.create("s", "S").view(view("s"))));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void a05_tabWithNeitherViewNorSubtabsNorContent_rejected() {
+ var page = PageDef.create("docs").tabs(Tab.create("t", "T"));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void a06_tabWithViewAndSubtabsNoContent_stillRejected() {
+ // Regression: content's addition must not loosen the pre-existing view+subtabs exclusion.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("t", "T").view(view("v")).subtabs(Subtab.create("s", "S").view(view("s"))));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void a07_tabWithViewOnly_stillAccepted() {
+ // Regression: the pre-420 leaf-view shape must still build cleanly.
+ var page = PageDef.create("docs").tabs(Tab.create("t", "T").view(view("v"))).build();
+ assertNotNull(page.tabs.get(0).view);
+ }
+
+ @Test void a08_tabWithSubtabsOnlyNoContent_stillAccepted() {
+ // Regression: the pre-420 sub-tabbed shape (no preamble) must still build cleanly.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("t", "T").subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ assertNull(page.tabs.get(0).content);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Subtab matrix: {view} | {content}
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_subtabWithContentOnly_accepted() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>Body.</p>")))
+ .build();
+ assertEquals("<p>Body.</p>", page.tabs.get(0).subtabs.get(0).content);
+ assertNull(page.tabs.get(0).subtabs.get(0).view);
+ }
+
+ @Test void b02_subtabWithViewOnly_stillAccepted() {
+ // Regression: the pre-420 subtab-must-have-a-view shape still builds cleanly.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").view(view("s"))))
+ .build();
+ assertNull(page.tabs.get(0).subtabs.get(0).content);
+ }
+
+ @Test void b03_subtabWithBothViewAndContent_rejected() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog")
+ .subtabs(Subtab.create("s", "S").view(view("s")).content("<p>x</p>")));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void b04_subtabWithNeitherViewNorContent_rejected() {
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S")));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+ }
+
+ @Test void b05_mixedViewAndContentSubtabsUnderOneTab_accepted() {
+ // A tab may mix view-backed and content-backed subtabs freely.
+ var page = PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("data", "Data").view(view("data")),
+ Subtab.create("about", "About").content("<p>About this catalog.</p>")))
+ .build();
+ assertNotNull(page.tabs.get(0).subtabs.get(0).view);
+ assertNotNull(page.tabs.get(0).subtabs.get(1).content);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // PageDef.build() consequences: must not NPE on content-only shapes (view-id dedup logic)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_contentOnlyTab_doesNotNpeOnViewIdDedup() {
+ // Pre-420, PageDef.validate() unconditionally iterated t.subtabs when t.view was null - a content-only
+ // tab (no subtabs at all) would NPE there. Must build cleanly instead.
+ assertDoesNotThrow(() -> PageDef.create("docs")
+ .tabs(Tab.create("overview", "Overview").content("<p>Hi.</p>"))
+ .build());
+ }
+
+ @Test void c02_contentOnlySubtab_doesNotNpeOnViewIdDedup() {
+ // Pre-420, PageDef.validate() unconditionally read s.view.id for every subtab - a content-only subtab
+ // (s.view == null) would NPE there. Must build cleanly instead.
+ assertDoesNotThrow(() -> PageDef.create("docs")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("s", "S").content("<p>Hi.</p>")))
+ .build());
+ }
+
+ @Test void c03_multipleContentOnlyTabs_doNotCollideOnAbsentViewIds() {
+ // Two content-only tabs (no ViewDef at all) must not be mistaken for a duplicate-view-id collision.
+ var page = PageDef.create("docs")
+ .tabs(
+ Tab.create("a", "A").content("<p>A</p>"),
+ Tab.create("b", "B").content("<p>B</p>"))
+ .build();
+ assertEquals(2, page.tabs.size());
+ }
+
+ @Test void c04_duplicateViewIdAcrossAContentTabAndAViewTab_stillDetected() {
+ // The dedup rule itself must still fire when an actual duplicate view id IS present, alongside an
+ // unrelated content-only tab.
+ var page = PageDef.create("docs")
+ .tabs(
+ Tab.create("a", "A").content("<p>A</p>"),
+ Tab.create("b", "B").view(view("shared")),
+ Tab.create("c", "C").view(view("shared")));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
+ assertTrue(e.getMessage().contains("shared"), e::getMessage);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
index 67b70d4c08..be7184bebb 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
@@ -113,14 +113,23 @@ class TablePolling_Wiring_Test extends TestBase {
assertTrue(fnBody.contains("if (document.hidden) return;"), fnBody);
}
- /** A plain interval fetch - not a streaming/SSE transport. */
+ /**
+ * A plain interval fetch - not a streaming/SSE transport.
+ *
+ * <p>
+ * Scoped to the {@code initPolling} function body: the TABLE-POLLING transport must stay a plain
+ * {@code setInterval} + {@code dt.ajax.reload} loop and must never itself become an {@code EventSource} stream.
+ * The check is deliberately NOT over the whole file: the async-job feature (TODO-425) legitimately opens an
+ * {@code EventSource} elsewhere (its own DISTINCT job-running affordance, HIGH-9), which must not freeze polling -
+ * so streaming may exist in the file, just never inside the polling loop.
+ */
@Test void b05_initPolling_isAPlainIntervalFetch_notAStreamingTransport() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
var fnBody = functionBody(body, "function initPolling(");
assertTrue(fnBody.contains("setInterval(poll, intervalMs)"), fnBody);
assertTrue(fnBody.contains("dt.ajax.reload(null, false)"), fnBody);
- assertFalse(body.toLowerCase().contains("eventsource"), body);
- assertFalse(body.contains("text/event-stream"), body);
+ assertFalse(fnBody.toLowerCase().contains("eventsource"), fnBody);
+ assertFalse(fnBody.contains("text/event-stream"), fnBody);
}
/** A failed round trip must flip a DISTINCT visible state, not just leave a frozen "fresh" timestamp. */
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
index 3ef8f2687c..662a54d517 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
@@ -53,7 +53,7 @@ class ViewMeta_Contract_Test extends TestBase {
*/
private static final String EXPECTED_VIEW_META = """
{
- "contractVersion": "2",
+ "contractVersion": "3",
"id": "releases",
"rowType": "Release",
"dataMode": "server",
@@ -125,9 +125,10 @@ class ViewMeta_Contract_Test extends TestBase {
@Test void a03_reservedFieldsOmittedNotNull() {
var json = Json.of(releasesView());
- // "details" is implemented - see e07/e08 below for its POSITIVE assertion. The remaining reserved C/D/E
- // stubs must still be omitted entirely (not serialized as null keys).
- for (var k : List.of("rowActions", "catalog", "format", "description", "pinned", "defaultVisible")) {
+ // "details" and "rowActions" are implemented - see f01-f04 (details) and g01-g05 (rowActions) below for
+ // their POSITIVE assertions. The remaining reserved C/D/E stubs must still be omitted entirely (not
+ // serialized as null keys).
+ for (var k : List.of("catalog", "format", "description", "pinned", "defaultVisible")) {
var key = "\"" + k + "\"";
assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
}
@@ -429,4 +430,81 @@ class ViewMeta_Contract_Test extends TestBase {
assertEquals("owner", d.data);
assertNull(d.title);
}
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) Row actions: the positive assertion for the "rowActions" key (moved out of a03's negative reserved list -
+ // same positive-assertion-plus-remaining-negatives split used for "details" in section f). This freezes the
+ // COMPLETE RowAction wire schema (TODO-399 Decision 8) so a later wave (TODO-416) can rely on it verbatim.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_rowActions_omittedWhenUnset() {
+ // The golden fixture view never calls .rowActions(...) - confirms the new field doesn't leak when absent,
+ // and that a01/a02's frozen contract is untouched by this additive field.
+ var json = Json.of(releasesView());
+ assertFalse(json.contains("\"rowActions\""), json);
+ }
+
+ @Test void g02_rowActions_emittedInPinnedOrder_withCompleteWireSchema() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+ .rowActions(
+ RowAction.create("ack").label("Acknowledge").icon("check").endpoint("servlet:/x/{id}/ack")
+ .method(RowAction.Method.POST).confirm("Acknowledge this?").form("servlet:/x/ack-form")
+ .present(RowAction.Present.DIALOG).onSuccess(RowAction.OnSuccess.MERGE_ROW))
+ .build();
+ var json = Json.of(v);
+ // Every RowAction wire field, in the pinned @BeanType order, with the exact tokens 416 will consume.
+ assertTrue(json.contains(
+ "\"rowActions\":[{\"id\":\"ack\",\"label\":\"Acknowledge\",\"icon\":\"check\","
+ + "\"endpoint\":\"servlet:/x/{id}/ack\",\"method\":\"POST\",\"confirm\":\"Acknowledge this?\","
+ + "\"form\":\"servlet:/x/ack-form\",\"present\":\"dialog\",\"onSuccess\":\"mergeRow\"}]"),
+ json);
+ }
+
+ @Test void g03_rowActions_omitsUnsetOptionalFields() {
+ // Only id is required; every other RowAction field is omitted (not null) when unset.
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+ .rowActions(RowAction.create("del").endpoint("servlet:/x/{id}").method(RowAction.Method.DELETE))
+ .build();
+ var json = Json.of(v);
+ assertTrue(json.contains("\"rowActions\":[{\"id\":\"del\",\"endpoint\":\"servlet:/x/{id}\",\"method\":\"DELETE\"}]"), json);
+ assertFalse(json.contains("\"confirm\""), json);
+ assertFalse(json.contains("\"present\""), json);
+ assertFalse(json.contains("\"onSuccess\""), json);
+ }
+
+ @Test void g04_rowActions_isAfterRowClassRules_andBeforeDetails_inTopLevelKeyOrder() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+ .rowClassRule("error", Op.PRESENT, "row-flagged")
+ .rowActions(RowAction.create("go").endpoint("u").method(RowAction.Method.POST))
+ .details(ViewDef.DetailDef.of("owner").title("Owner"))
+ .poll(60_000L)
+ .build();
+ Map<?,?> actual = Json.to(Json.of(v), Map.class);
+ assertEquals(
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "rowActions", "details", "pollIntervalMs"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void g05_rowActionMethodTokensAreTheNonSafeMethods() {
+ // The Method enum makes a safe method (GET/HEAD/OPTIONS/TRACE) unexpressible - the build-time half of the
+ // HIGH-7 refusal - and each token is the uppercase HTTP method the client submits with.
+ assertEquals("POST", RowAction.Method.POST.wire());
+ assertEquals("PUT", RowAction.Method.PUT.wire());
+ assertEquals("PATCH", RowAction.Method.PATCH.wire());
+ assertEquals("DELETE", RowAction.Method.DELETE.wire());
+ }
+
+ @Test void g06_rowActionCreateBlankIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> RowAction.create(" "));
+ assertTrue(e.getMessage().contains("blank"), e::getMessage);
+ }
+
+ @Test void g07_rowActionPresentAndOnSuccessTokens() {
+ assertEquals("page", RowAction.Present.PAGE.wire());
+ assertEquals("dialog", RowAction.Present.DIALOG.wire());
+ assertEquals("inline", RowAction.Present.INLINE.wire());
+ assertEquals("redraw", RowAction.OnSuccess.REDRAW.wire());
+ assertEquals("mergeRow", RowAction.OnSuccess.MERGE_ROW.wire());
+ assertEquals("navigate", RowAction.OnSuccess.NAVIGATE.wire());
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
new file mode 100644
index 0000000000..4dc0107e96
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@code TODO-428}: {@link ViewTable}'s row-selection and bulk-mutation rendering — the DOM-shape half of the
+ * separability guarantee (design-doc HIGH-5) and the R2 client-only/non-wire guard.
+ */
+class ViewTable_SelectionBulk_Test extends TestBase {
+
+ public static class Incident {
+ public String incidentId;
+ public String status;
+ public Incident(String incidentId, String status) { this.incidentId = incidentId; this.status = status; }
+ }
+
+ private static ViewDef view() {
+ return ViewDef.create("incidents")
+ .rowType(Incident.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/incidents/data")
+ .columns(Column.of("incidentId").title("Incident"), Column.of("status").title("Status"))
+ .build();
+ }
+
+ private static String bulkSidecarBody(String html, String viewId) {
+ var marker = "id=\"" + ViewTable.BULK_SIDECAR_ID_PREFIX + viewId + "\"";
+ var open = html.indexOf(marker);
+ assertTrue(open >= 0, () -> "bulk sidecar not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ private static String viewSidecarBody(String html, String viewId) {
+ var marker = "id=\"juneau-view:" + viewId + "\"";
+ var open = html.indexOf(marker);
+ assertTrue(open >= 0, () -> "view sidecar not found:\n" + html);
+ var contentStart = html.indexOf('>', open) + 1;
+ var contentEnd = html.indexOf("</script>", contentStart);
+ return html.substring(contentStart, contentEnd);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) An ordinary table (no selection) - unaffected, no new attributes at all.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_ordinaryTable_hasNeitherSelectNorBulkAttributes() {
+ var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open"))));
+ assertFalse(html.contains(ViewTable.SELECT_ATTR), html);
+ assertFalse(html.contains(ViewTable.BULK_ATTR), html);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Selection-only opt-in (e.g. selectable-for-export, PagerDuty-style: selection WITHOUT bulk mutation)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_selectionOnly_stampsSelectAttrsButNeverBulk() {
+ var selection = SelectionDef.create("incidentId");
+ var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), selection));
+ assertTrue(html.contains(ViewTable.SELECT_ATTR + "=\"1\""), html);
+ assertTrue(html.contains(ViewTable.ROW_ID_FIELD_ATTR + "=\"incidentId\""), html);
+ assertTrue(html.contains(ViewTable.SELECT_ALL_ATTR + "=\"1\""), html); // selectAll() default true
+ assertTrue(html.contains("juneau-view-select-th"), html);
+ // The load-bearing separability fact (HIGH-5): a selection-only table has NO bulk affordance anywhere -
+ // not the marker attribute, not a bulk sidecar - there is no code path in ViewTable that could add one
+ // from a bare SelectionDef.
+ assertFalse(html.contains(ViewTable.BULK_ATTR), html);
+ assertFalse(html.contains(ViewTable.BULK_SIDECAR_ID_PREFIX), html);
+ }
+
+ @Test void b02_selectAllFalse_stampsZero() {
+ var selection = SelectionDef.create("incidentId").selectAll(false);
+ var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), selection));
+ assertTrue(html.contains(ViewTable.SELECT_ALL_ATTR + "=\"0\""), html);
+ }
+
+ @Test void b03_nullSelection_throws() {
+ assertThrows(IllegalArgumentException.class, () -> ViewTable.of(view(), List.of(), (SelectionDef) null));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Bulk-mutate opt-in - requires (and renders) selection too, plus its OWN independently-versioned sidecar.
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static BulkMutateDef bulkDef(SelectionDef selection) {
+ var ack = RowAction.create("ack").label("Acknowledge").endpoint("servlet:/incidents/bulk-ack").method(RowAction.Method.POST);
+ return BulkMutateDef.create(WritePermit.forCapability("incidents:ack"), selection).actions(ack);
+ }
+
+ @Test void c01_bulkMutate_stampsSelectAndBulkAttrs_plusBulkSidecar() {
+ var selection = SelectionDef.create("incidentId");
+ var bulk = bulkDef(selection);
+ var html = Html.of(ViewTable.of(view(), List.of(new Incident("INC-1", "open")), bulk));
+
+ assertTrue(html.contains(ViewTable.SELECT_ATTR + "=\"1\""), html);
+ assertTrue(html.contains(ViewTable.BULK_ATTR + "=\"1\""), html);
+ assertTrue(html.contains("id=\"" + ViewTable.BULK_SIDECAR_ID_PREFIX + "incidents\""), html);
+
+ var body = bulkSidecarBody(html, "incidents");
+ var expected = Json.to(Json.of(bulk), Map.class);
+ var actual = Json.to(body, Map.class);
+ assertEquals(expected, actual, body);
+ assertEquals(Set.of("contractVersion", "actions"), actual.keySet(), body);
+ }
+
+ @Test void c02_nullBulkMutate_throws() {
+ assertThrows(IllegalArgumentException.class, () -> ViewTable.of(view(), List.of(), (BulkMutateDef) null));
+ }
+
+ /**
+ * The compile/DOM-shape guarantee from the OTHER direction: {@code ViewTable.of(..., BulkMutateDef)} has no
+ * parameter through which a caller could supply a DIFFERENT {@link SelectionDef} than the one the
+ * {@link BulkMutateDef} was built against - the overload only ever reads {@code bulkMutate.selection()}. The
+ * lower-level core method enforces this explicitly when a caller mixes the two by hand.
+ */
+ @Test void c03_core_rejectsASelectionThatIsNotTheBulkDefsOwnSelection() {
+ var selectionA = SelectionDef.create("incidentId");
+ var selectionB = SelectionDef.create("incidentId"); // a different instance, same field name
+ var bulk = bulkDef(selectionA);
+ assertThrows(IllegalArgumentException.class,
+ () -> ViewTable.of(MarshallingContext.DEFAULT, view(), List.of(), null, selectionB, bulk));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) R2 guard: selection/bulk are 100% client-only/non-wire - VIEW_META is byte-identical with or without them.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_viewMetaSidecarIsUnaffectedByEitherOptIn() {
+ var v = view();
+ var plainHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open"))));
+ var selectionHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open")), SelectionDef.create("incidentId")));
+ var bulkHtml = Html.of(ViewTable.of(v, List.of(new Incident("INC-1", "open")), bulkDef(SelectionDef.create("incidentId"))));
+
+ var plainMeta = viewSidecarBody(plainHtml, "incidents");
+ var selectionMeta = viewSidecarBody(selectionHtml, "incidents");
+ var bulkMeta = viewSidecarBody(bulkHtml, "incidents");
+
+ // Byte-identical VIEW_META regardless of the opt-in: selection/bulk never touch ViewDef's serialized form.
+ assertEquals(plainMeta, selectionMeta, "SelectionDef must not alter VIEW_META");
+ assertEquals(plainMeta, bulkMeta, "BulkMutateDef must not alter VIEW_META");
+ assertFalse(plainMeta.toLowerCase().contains("select"), plainMeta);
+ assertFalse(plainMeta.toLowerCase().contains("bulk"), plainMeta);
+ }
+
+ @Test void d02_viewDefContractVersionUnchanged() {
+ // Sanity: this wave must not have bumped the existing VIEW_META contract version.
+ assertEquals("3", ViewDef.CONTRACT_VERSION);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java
new file mode 100644
index 0000000000..1738c720db
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_AsyncJobs_Test.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} async-job + SSE-streaming plumbing (TODO-425).
+ * Mirrors {@link ViewsJs_ModalResult_Test}'s served-script substring style: proves the load-bearing pieces of the
+ * async contract are present in the shipped asset without booting a browser (the behavioral proof lives in the opt-in
+ * {@code AsyncJob_BrowserTest} canary).
+ *
+ * <p>
+ * The single most important invariant asserted here is HIGH-9: the job-running affordance is a DISTINCT marker
+ * ({@code data-juneau-job}) that {@code hasInFlightRow} does NOT read, so a long job never freezes the table's
+ * polling the way the synchronous {@code data-juneau-inflight} marker does.
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_AsyncJobs_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static String fn(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> signature + " not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) HIGH-9: the job-running affordance is a DISTINCT marker that never freezes polling
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_setRowJobRunning_setsDistinctJobMarker_neverTheInflightMarker() throws Exception {
+ var s = fn(viewsJs(), "function setRowJobRunning(");
+ assertTrue(s.contains("setAttribute(\"data-juneau-job\""), s);
+ assertTrue(s.contains("removeAttribute(\"data-juneau-job\")"), s);
+ // The whole point of HIGH-9: a job must NEVER set the synchronous in-flight marker that freezes polling.
+ assertFalse(s.contains("data-juneau-inflight"), () -> "job affordance must NOT touch data-juneau-inflight:\n" + s);
+ // A second job can't be launched on the same row while one runs.
+ assertTrue(s.contains("trigger.disabled"), s);
+ }
+
+ @Test void a02_hasInFlightRow_ignoresTheJobMarker_soPollingKeepsRunningDuringAJob() throws Exception {
+ // hasInFlightRow reads ONLY data-juneau-inflight; it must NOT read data-juneau-job, or a job would freeze
+ // the whole table's polling for up to the 120s hard timeout (hiding a resurrected incident) - exactly the
+ // failure HIGH-9 forbids.
+ var h = fn(viewsJs(), "function hasInFlightRow(");
+ assertTrue(h.contains("data-juneau-inflight"), h);
+ assertFalse(h.contains("data-juneau-job"), () -> "hasInFlightRow must ignore the job marker:\n" + h);
+ }
+
+ @Test void a03_startJobStream_usesTheJobAffordance_neverTheInflightMarker() throws Exception {
+ var s = fn(viewsJs(), "function startJobStream(");
+ assertTrue(s.contains("setRowJobRunning(tr, true)"), s);
+ assertTrue(s.contains("setRowJobRunning(tr, false)"), s);
+ // It must not co-opt the synchronous in-flight lifecycle.
+ assertFalse(s.contains("setRowInFlight("), () -> "startJobStream must not use the sync in-flight marker:\n" + s);
+ assertFalse(s.contains("data-juneau-inflight"), s);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) The async start is detected by RESPONSE SHAPE (a job pointer), not a new RowAction wire field
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_parseJobStarted_requiresANonBlankStreamUrl_andIsDisjointFromATypedResult() throws Exception {
+ var p = fn(viewsJs(), "function parseJobStarted(");
+ assertTrue(p.contains("isBlankToken(o.streamUrl)"), p);
+ // The two 2xx shapes are disjoint: a job pointer is recognized by streamUrl, a terminal result by outcome.
+ var settle = fn(viewsJs(), "function settleActionResponse(");
+ assertTrue(settle.contains("parseJobStarted(text)"), settle);
+ assertTrue(settle.contains("startJobStream("), settle);
+ // The job branch is checked BEFORE the typed-result branch.
+ assertTrue(settle.indexOf("parseJobStarted(text)") < settle.indexOf("parseActionResult(text)"), settle);
+ }
+
+ @Test void b02_settleClearsInflightBeforeRoutingToTheJobStream_soPollingResumesFirst() throws Exception {
+ // The in-flight marker is cleared as the FIRST statement of settle (417), so table polling has already
+ // resumed before the (possibly 120s) job even starts streaming - the job then only ever carries the
+ // distinct data-juneau-job marker.
+ var settle = fn(viewsJs(), "function settleActionResponse(");
+ var open = settle.indexOf("{");
+ var firstStmt = settle.substring(open + 1, settle.indexOf(";", open));
+ assertTrue(firstStmt.contains("setRowInFlight(tr, false)"), () -> "settle must clear the in-flight marker first:\n" + settle);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) The stream URL IS the capability (HIGH-4): an EventSource GET, no CSRF header on the stream
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_startJobStream_opensAnEventSourceOnTheCapabilityStreamUrl() throws Exception {
+ var s = fn(viewsJs(), "function startJobStream(");
+ assertTrue(s.contains("new EventSource(started.streamUrl)"), s);
+ // A browser EventSource cannot set headers, so the stream carries NO CSRF header - unguessability is the gate.
+ assertFalse(s.contains(DEFAULT_CSRF_HEADER_LITERAL), () -> "the SSE GET must not attach a CSRF header:\n" + s);
+ // Progress + a single terminal result event; a stream error is itself a non-optimistic terminal outcome.
+ assertTrue(s.contains("addEventListener(\"progress\""), s);
+ assertTrue(s.contains("addEventListener(\"result\""), s);
+ assertTrue(s.contains("addEventListener(\"error\""), s);
+ }
+
+ /** The exact header token the runtime uses for the CSRF header (so c01's negative assertion is meaningful). */
+ private static final String DEFAULT_CSRF_HEADER_LITERAL = "X-Csrf-Token";
+
+ @Test void c02_missingEventSourceDegradesToAVisibleUnknown_notASilentHang() throws Exception {
+ var s = fn(viewsJs(), "function startJobStream(");
+ assertTrue(s.contains("typeof EventSource === \"undefined\""), s);
+ assertTrue(s.contains("outcome: \"unknown\""), s);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Live progress is painted with textContent ONLY (never innerHTML) - the streamed content is customer-adjacent
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_renderJobProgress_usesTextContentNeverInnerHtml_andWiresCancel() throws Exception {
+ var body = viewsJs();
+ var r = fn(body, "function renderJobProgress(");
+ assertTrue(r.contains(".textContent"), r);
+ assertFalse(r.contains(".innerHTML"), () -> "job progress must never use innerHTML:\n" + r);
+ assertFalse(r.contains("insertAdjacentHTML"), r);
+ assertTrue(r.contains("juneau-view-job-progress"), r);
+ assertTrue(r.contains("juneau-view-job-cancel"), r);
+ assertTrue(r.contains("cancelJob(started, table, tr)"), r);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Terminal result reuses the SAME contract handshake + outcome render (cancelled / cancelled-after-effect)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_finishJobFromResult_reusesContractHandshakeAndOutcomeRender() throws Exception {
+ var f = fn(viewsJs(), "function finishJobFromResult(");
+ assertTrue(f.contains("result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), f);
+ assertTrue(f.contains("normalizeOutcome(result)"), f);
+ assertTrue(f.contains("applySuccessBehavior("), f);
+ assertTrue(f.contains("renderActionOutcome("), f);
+ }
+
+ @Test void e02_actionOutcomeMessage_rendersBothReservedAsyncOutcomes() throws Exception {
+ // The frozen ActionResult contract reserved cancelled / cancelled-after-effect; the runtime renders both
+ // without any new UI (Q4: the two are DIFFERENT outcomes and must not be collapsed).
+ var body = viewsJs();
+ var m = fn(body, "function actionOutcomeMessage(");
+ assertTrue(m.contains("case \"cancelled\":"), m);
+ assertTrue(m.contains("case \"cancelled-after-effect\":"), m);
+ // And they are recognized outcomes (not normalized away to unknown).
+ assertTrue(body.contains("\"cancelled\": 1"), body);
+ assertTrue(body.contains("\"cancelled-after-effect\": 1"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Cancel is a fail-closed non-safe POST; the SERVER is authoritative
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_buildJobCancelRequest_isAFailClosedCsrfPost() throws Exception {
+ var b = fn(viewsJs(), "function buildJobCancelRequest(");
+ assertTrue(b.contains("no-cancel-url"), b);
+ assertTrue(b.contains("missing-token"), b); // fail-closed: a blank token sends nothing
+ assertTrue(b.contains("method: \"POST\""), b); // a non-safe write, so the boundary's full checks apply
+ assertTrue(b.contains(DEFAULT_CSRF_HEADER_LITERAL) || b.contains("DEFAULT_CSRF_HEADER"), b);
+ }
+
+ @Test void f02_asyncFunctionsAreExposedOnThePublicApi() throws Exception {
+ var body = viewsJs();
+ for (var name : new String[]{"parseJobStarted", "buildJobCancelRequest", "setRowJobRunning", "renderJobProgress",
+ "clearJobProgress", "startJobStream", "finishJobFromResult", "cancelJob"})
+ assertTrue(body.contains(name + ": " + name), () -> "async function not exposed on NS.init: " + name);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
new file mode 100644
index 0000000000..5cbcb2bfe6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.filter.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} declarative-modal, typed-action-result and
+ * in-flight-row plumbing (TODO-416/417). Mirrors {@link ViewsJs_RowActions_Test}'s served-script substring style:
+ * proves the load-bearing pieces of the modal/result/in-flight contract are present in the shipped asset, without
+ * booting a browser (the behavioral proof lives in the opt-in {@code ModalResult_BrowserTest} canary).
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_ModalResult_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static String fn(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> signature + " not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) The typed action-result contract version is its OWN constant, not aliased to the view contract (MED-7)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_actionResultContractVersion_isItsOwnConstant() throws Exception {
+ var body = viewsJs();
+ // The runtime constant must equal the server's ActionResult.CONTRACT_VERSION, and must NOT be the view one.
+ assertTrue(body.contains("JUNEAU_ACTION_RESULT_CONTRACT_VERSION = \"" + ActionResult.CONTRACT_VERSION + "\""), body);
+ assertEquals("1", ActionResult.CONTRACT_VERSION);
+ assertNotEquals(ViewDef.CONTRACT_VERSION, ActionResult.CONTRACT_VERSION);
+ }
+
+ @Test void a02_contractMismatchOn2xxRendersUnknown() throws Exception {
+ // A 2xx typed result whose contractVersion differs is a visible, non-optimistic UNKNOWN - never silently misread.
+ var body = viewsJs();
+ var settle = fn(body, "function settleActionResponse(");
+ assertTrue(settle.contains("result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), settle);
+ assertTrue(settle.contains("outcome: \"unknown\""), settle);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) The modal-open confirmation renders TYPED FIELDS via textContent - never innerHTML / raw markup (BLK-1/MED-9)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_dialogOverlayUsesTextContentNeverInnerHtml() throws Exception {
+ var body = viewsJs();
+ var build = fn(body, "function buildDialogOverlay(");
+ assertTrue(build.contains(".textContent"), build);
+ assertFalse(build.contains(".innerHTML"), () -> "dialog overlay must never use innerHTML:\n" + build);
+ assertFalse(build.contains("insertAdjacentHTML"), build);
+ // The typed confirmation fields are painted as dt/dd label+value pairs.
+ assertTrue(build.contains("juneau-view-dialog-fields"), build);
+ }
+
+ @Test void b02_settleAndOutcomePathsNeverUseInnerHtml() throws Exception {
+ // The write-result render path (settle -> outcome banner) must not use an HTML sink; the pre-existing
+ // innerHTML uses live only in the trusted icon/caret render helpers, never in the action-result path.
+ var body = viewsJs();
+ for (var sig : new String[]{"function settleActionResponse(", "function renderActionOutcome(",
+ "function showActionDialog(", "function submitActionDialog(", "function openActionDialog("}) {
+ var f = fn(body, sig);
+ assertFalse(f.contains(".innerHTML"), () -> sig + " must never use innerHTML:\n" + f);
+ assertFalse(f.contains("insertAdjacentHTML"), () -> sig + " must never use insertAdjacentHTML:\n" + f);
+ }
+ }
+
+ @Test void b03_dialogIsOpenedForPresentDialogActions() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("function isDialogAction("), body);
+ assertTrue(body.contains("action.present === \"dialog\""), body);
+ assertTrue(body.contains("function openActionDialog("), body);
+ // The modal-open confirmation fetch is a read-only GET.
+ var open = fn(body, "function openActionDialog(");
+ assertTrue(open.contains("method: \"GET\""), open);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) A non-2xx is a VISIBLE transport refusal read from the boundary envelope - not "HTTP 200 + schema" (HIGH-3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_nonOkReadsLoopbackBoundaryEnvelope() throws Exception {
+ var body = viewsJs();
+ var settle = fn(body, "function settleActionResponse(");
+ assertTrue(settle.contains("! resp.ok"), settle);
+ assertTrue(settle.contains("X-Loopback-Boundary"), settle);
+ assertTrue(settle.contains("transport: true"), settle);
+ }
+
+ @Test void c02_transportRefusalPrefersBoundaryReasonThenEnvelope() throws Exception {
+ var body = viewsJs();
+ var t = fn(body, "function transportRefusal(");
+ assertTrue(t.contains("boundaryReason"), t);
+ assertTrue(t.contains("envelope"), t);
+ // The header name the boundary filter actually sets on a rejection.
+ assertEquals("X-Loopback-Boundary", LoopbackBoundaryFilter.REJECTION_HEADER);
+ }
+
+ @Test void c03_transportStatusMessagesCoverTheMappedCodes() throws Exception {
+ // 403/415/421 all map to a visible non-optimistic message.
+ var body = viewsJs();
+ var m = fn(body, "function transportStatusMessage(");
+ assertTrue(m.contains("403"), m);
+ assertTrue(m.contains("415"), m);
+ assertTrue(m.contains("421"), m);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) All four outcomes render; the outcome banner is visible + assistive
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_renderActionOutcomePaintsVisibleBanner() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("function renderActionOutcome("), body);
+ assertTrue(body.contains("juneau-view-action-outcome"), body);
+ var r = fn(body, "function renderActionOutcome(");
+ assertTrue(r.contains(".textContent"), r);
+ assertTrue(r.contains("\"status\""), r); // success -> role=status
+ assertTrue(r.contains("\"alert\""), r); // everything else -> role=alert
+ }
+
+ @Test void d02_outcomeMessageCoversAllFourSyncOutcomes() throws Exception {
+ var body = viewsJs();
+ var m = fn(body, "function actionOutcomeMessage(");
+ for (var o : new String[]{"success", "failure", "refusal"})
+ assertTrue(m.contains("case \"" + o + "\":"), () -> o + " not handled:\n" + m);
+ // unknown is the default branch.
+ assertTrue(m.contains("Outcome unknown"), m);
+ }
+
+ @Test void d03_normalizeOutcomeFallsBackToUnknown() throws Exception {
+ var body = viewsJs();
+ var n = fn(body, "function normalizeOutcome(");
+ assertTrue(n.contains("\"unknown\""), n);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) 417: in-flight marker set on the SYNC write, cleared on EVERY terminal outcome (incl. UNKNOWN) (MED-4)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_setRowInFlightTogglesMarkerAndDisablesTrigger() throws Exception {
+ var body = viewsJs();
+ var s = fn(body, "function setRowInFlight(");
+ assertTrue(s.contains("setAttribute(\"data-juneau-inflight\""), s);
+ assertTrue(s.contains("removeAttribute(\"data-juneau-inflight\")"), s);
+ assertTrue(s.contains("trigger.disabled"), s);
+ }
+
+ @Test void e02_settleClearsMarkerFirstOnEveryOutcome() throws Exception {
+ // The very first statement of settle must clear the marker so polling ALWAYS resumes - the UNKNOWN clear is
+ // a hard requirement (hasInFlightRow/initPolling freeze polling while ANY marker is set).
+ var body = viewsJs();
+ var settle = fn(body, "function settleActionResponse(");
+ var open = settle.indexOf("{");
+ var firstStmt = settle.substring(open + 1, settle.indexOf(";", open));
+ assertTrue(firstStmt.contains("setRowInFlight(tr, false)"),
+ () -> "settleActionResponse must clear the in-flight marker FIRST:\n" + settle);
+ }
+
+ @Test void e03_submitSetsMarkerAndNetworkFailureClearsIt() throws Exception {
+ var body = viewsJs();
+ var submit = fn(body, "function submitRowAction(");
+ assertTrue(submit.contains("setRowInFlight(tr, true)"), submit);
+ // A network-level failure is itself a terminal outcome: clear the marker (polling resumes) + visible refusal.
+ assertTrue(submit.contains("setRowInFlight(tr, false)"), submit);
+ assertTrue(submit.contains("request-failed"), submit);
+ }
+
+ @Test void e04_pollingFreezeStillKeyedOnTheSameMarker() throws Exception {
+ // The marker attribute the 417 lifecycle toggles is the exact one hasInFlightRow reads to freeze polling.
+ var body = viewsJs();
+ var h = fn(body, "function hasInFlightRow(");
+ assertTrue(h.contains("data-juneau-inflight"), h);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Re-render from the authoritative result payload (MERGE_ROW)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_mergeRowFromResultReRendersFromPayload() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("function mergeRowFromResult("), body);
+ var apply = fn(body, "function applySuccessBehavior(");
+ assertTrue(apply.contains("mergeRow"), apply);
+ assertTrue(apply.contains("result.row"), apply);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) The submit carries the idempotency key + target id bound at modal-open
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_submitDialogCarriesIdempotencyKeyAndTargetId() throws Exception {
+ var body = viewsJs();
+ var s = fn(body, "function submitActionDialog(");
+ assertTrue(s.contains("idempotencyKey"), s);
+ assertTrue(s.contains("targetId"), s);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
new file mode 100644
index 0000000000..77c4308b88
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.filter.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} row-action + fail-closed CSRF plumbing
+ * (TODO-415). Mirrors {@code ViewsMixin_Serving_Test}'s served-script substring style: proves the load-bearing
+ * pieces of the runtime's row-menu/submit contract are present in the shipped asset, without booting a browser
+ * (the behavioral proof lives in the opt-in {@code RowActionCsrf_BrowserTest} canary).
+ *
+ * <p>
+ * The client refusal these tests pin is <b>defense-against-consumer-omission</b>, not the security control: the
+ * landed server-side {@link LoopbackBoundary} is. The value of pinning the shapes here is that the two halves must
+ * agree by construction — same default header, same JSON content type, same {@code isBlank}-not-{@code isEmpty}
+ * fail-closed test, same non-safe-method rule.
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_RowActions_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/items") public String items() { return "items"; }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ @Test void a01_contractVersionBumpedToThree() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION = \"3\""), body);
+ assertEquals("3", ViewDef.CONTRACT_VERSION);
+ }
+
+ @Test void a02_defaultCsrfHeaderMatchesTheServerBoundary() throws Exception {
+ // The runtime's default header MUST equal LoopbackBoundary.DEFAULT_CSRF_HEADER or the two halves disagree.
+ var body = viewsJs();
+ assertTrue(body.contains("DEFAULT_CSRF_HEADER = \"" + LoopbackBoundary.DEFAULT_CSRF_HEADER + "\""), body);
+ assertEquals("X-Csrf-Token", LoopbackBoundary.DEFAULT_CSRF_HEADER);
+ }
+
+ @Test void a03_failClosedTokenTestUsesBlankNotEmpty() throws Exception {
+ // isBlankToken must match the boundary's check() (isBlank), so a WHITESPACE token also refuses - not the
+ // SynchronizerToken.matches() isEmpty(), which would let whitespace through to a confusing 403.
+ var body = viewsJs();
+ var start = body.indexOf("function isBlankToken(");
+ assertTrue(start >= 0, () -> "isBlankToken not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ var fn = body.substring(start, end < 0 ? body.length() : end);
+ assertTrue(fn.contains(".trim()"), fn); // whitespace-sensitive
+ }
+
+ @Test void a04_actionSubmitSendsJsonBodyAndCsrfHeader() throws Exception {
+ var body = viewsJs();
+ var start = body.indexOf("function buildActionRequest(");
+ assertTrue(start >= 0, () -> "buildActionRequest not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ var fn = body.substring(start, end < 0 ? body.length() : end);
+ // JSON content type (so the write passes LoopbackBoundary.isJson) + the token under the header name.
+ assertTrue(fn.contains("\"Content-Type\": \"application/json\""), fn);
+ assertTrue(fn.contains("JSON.stringify("), fn);
+ assertTrue(fn.contains("headerName || DEFAULT_CSRF_HEADER"), fn);
+ // Both fail-closed refusals: safe/absent method, and blank token.
+ assertTrue(fn.contains("isSafeMethod(action.method)"), fn);
+ assertTrue(fn.contains("isBlankToken(token)"), fn);
+ assertTrue(fn.contains("refuse: true"), fn);
+ }
+
+ @Test void a05_safeMethodSetMirrorsMethodSafety() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("const SAFE_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1, TRACE: 1 }"), body);
+ }
+
+ @Test void a06_rowMenuAndTriggerAreWired() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("function initRowActions("), body);
+ assertTrue(body.contains("function buildRowActionMenu("), body);
+ assertTrue(body.contains("juneau-view-action-trigger"), body);
+ assertTrue(body.contains("juneau-view-action-menu"), body);
+ }
+
+ @Test void a07_refusalIsVisibleNotSilent() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("function renderRowActionRefusal("), body);
+ assertTrue(body.contains("juneau-view-action-refusal"), body);
+ assertTrue(body.contains("role\", \"alert\""), body);
+ }
+
+ @Test void a08_readsAutoEmbeddedCsrfAttribute() throws Exception {
+ // The runtime reads the token ViewTable auto-embeds under data-juneau-csrf (ViewTable.CSRF_ATTR).
+ var body = viewsJs();
+ assertTrue(body.contains("data-juneau-csrf"), body);
+ assertEquals("data-juneau-csrf", ViewTable.CSRF_ATTR);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
new file mode 100644
index 0000000000..14447d8504
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the {@code juneau-views.js} row-selection + bulk-mutation plumbing
+ * ({@code TODO-428}). Mirrors {@link ViewsJs_RowActions_Test}'s served-script substring style: proves the
+ * load-bearing pieces of the two-independent-opt-ins contract are present in the shipped asset, without booting a
+ * browser (the behavioral proof lives in the opt-in {@code RowSelectionBulk_BrowserTest} canary).
+ */
+@SuppressWarnings({
+ "resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Selection_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+ private static String viewsJs() throws Exception {
+ return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ }
+
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) DOM attribute names mirror ViewTable's constants exactly - both halves must agree by construction.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_selectionDomAttrNamesMirrorViewTable() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("const SELECT_ATTR = \"" + ViewTable.SELECT_ATTR + "\""), body);
+ assertTrue(body.contains("const ROW_ID_ATTR = \"data-juneau-row-id\""), body);
+ assertTrue(body.contains("const ROW_ID_FIELD_ATTR = \"" + ViewTable.ROW_ID_FIELD_ATTR + "\""), body);
+ assertTrue(body.contains("const SELECT_ALL_ATTR = \"" + ViewTable.SELECT_ALL_ATTR + "\""), body);
+ assertTrue(body.contains("const BULK_ATTR = \"" + ViewTable.BULK_ATTR + "\""), body);
+ assertTrue(body.contains("const BULK_SIDECAR_ID_PREFIX = \"" + ViewTable.BULK_SIDECAR_ID_PREFIX + "\""), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) A THIRD, independently-versioned contract for bulk actions - never aliased to VIEW_META or the
+ // action-result contract (R2 guard).
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_bulkContractVersionIsItsOwnThirdConstant() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("const JUNEAU_BULK_CONTRACT_VERSION = \"" + BulkMutateDef.CONTRACT_VERSION + "\""), body);
+ // Three DISTINCT contract-version constants must all be present - none aliased to another.
+ assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION"), body);
+ assertTrue(body.contains("JUNEAU_ACTION_RESULT_CONTRACT_VERSION"), body);
+ assertTrue(body.contains("NS.BULK_CONTRACT_VERSION = JUNEAU_BULK_CONTRACT_VERSION"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Selection identity is the STABLE ROW ID (MED-11) - rowIdOf/stampRowId never fall back to a DOM index.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_rowIdOf_resolvesFromRowDataOnly_neverAnIndex() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function rowIdOf(");
+ assertTrue(fn.contains("rowData[rowIdField]"), fn);
+ // No index-based fallback anywhere in this function - the whole point of MED-11.
+ assertFalse(fn.contains("index"), fn);
+ }
+
+ @Test void c02_stampRowId_writesTheStableIdAttribute_neverAnIndex() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function stampRowId(");
+ assertTrue(fn.contains("rowIdOf(rowData, rowIdField)"), fn);
+ assertTrue(fn.contains("rowEl.setAttribute(ROW_ID_ATTR"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) The off-screen-id-drop persistence rule (Q2/MED-11) - pure, DOM-free.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_pruneSelection_dropsIdsNotInTheCurrentDraw() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function pruneSelection(");
+ assertTrue(fn.contains("present[String(id)] = true"), fn);
+ assertTrue(fn.contains("Object.hasOwn(present, String(id))"), fn);
+ }
+
+ @Test void d02_initSelection_prunesOnEveryDraw() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function initSelection(");
+ assertTrue(fn.contains("\"draw.dt\""), fn);
+ assertTrue(fn.contains("pruneSelection(Array.from(selectionState.selected), currentRowIds())"), fn);
+ assertTrue(fn.contains("selectionState.selected = new Set(pruned)"), fn);
+ }
+
+ @Test void e01_selectAll_isScopedToTheCurrentDrawsRowsOnly() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function initSelection(");
+ // The select-all header checkbox only ever iterates rows currently in the tbody - never an off-screen page.
+ assertTrue(fn.contains("SELECT_ALL_ATTR"), fn);
+ assertTrue(fn.contains("tbody tr[\" + ROW_ID_ATTR + \"]"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Two INDEPENDENT opt-ins (HIGH-5): hasBulk(...) is only ever reachable from inside the hasSelection(...)
+ // branch of initTable - selection alone can never surface a bulk-mutate control.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_initTable_bulkIsOnlyEverConsultedInsideTheSelectionBranch() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function initTable(");
+ var selectionIdx = fn.indexOf("const selectionState = hasSelection(table)");
+ var selectionBranchIdx = fn.indexOf("if (selectionState) {");
+ var bulkCheckIdx = fn.indexOf("hasBulk(table)");
+ var bulkToolbarIdx = fn.indexOf("buildBulkToolbar(bulkDef, table, ctx, selectionState)");
+ assertTrue(selectionIdx >= 0, fn);
+ assertTrue(selectionBranchIdx > selectionIdx, fn);
+ assertTrue(bulkCheckIdx > selectionBranchIdx, fn);
+ assertTrue(bulkToolbarIdx > bulkCheckIdx, fn);
+ }
+
+ @Test void f02_initTable_unshiftsASyntheticLeadingSelectionColumn() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function initTable(");
+ assertTrue(fn.contains("opts.columns.unshift(buildSelectionColumnDef(selectionState))"), fn);
+ }
+
+ @Test void f03_initTable_selectionWiringIsUnconditionalOnBulkHealth() throws Exception {
+ // initSelection(...) must run whenever selection was declared, REGARDLESS of whether the bulk sidecar is
+ // present/healthy - selection (e.g. for export) must keep working even if bulk mutation is withheld.
+ var body = viewsJs();
+ var fn = functionBody(body, "function initTable(");
+ var initSelectionIdx = fn.indexOf("initSelection(table, dt, selectionState, ctx)");
+ var bulkCheckIdx = fn.indexOf("hasBulk(table)");
+ assertTrue(initSelectionIdx >= 0 && initSelectionIdx < bulkCheckIdx, fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) Per-target bulk execution (HIGH-5) - N independent submitRowAction(...) calls, never one aggregate request.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_executeBulkAction_isAPerTargetLoop_notAnAggregateCall() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function executeBulkAction(");
+ assertTrue(fn.contains("ids.forEach(function (id) {"), fn);
+ assertTrue(fn.contains("submitRowAction(action, table, tr, ctx, { targetId: id })"), fn);
+ // No aggregate transport - a bulk action never opens its own fetch/Promise.all; it only reuses the
+ // single-row submit path once per target.
+ assertFalse(fn.contains("Promise.all"), fn);
+ assertFalse(fn.contains("fetch("), fn);
+ }
+
+ @Test void g02_executeBulkAction_skipsIdsThatHaveGoneOffScreen() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function executeBulkAction(");
+ assertTrue(fn.contains("if (!tr) return;"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // h) Bulk toolbar reflects the live selection count and gates on it (nothing to target with zero selected).
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_bulkToolbar_disablesButtonsWhenSelectionIsEmpty() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function buildBulkToolbar(");
+ assertTrue(fn.contains("btn.disabled = true"), fn);
+ assertTrue(fn.contains("b.disabled = count === 0"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // i) A missing/contract-mismatched bulk sidecar is withheld, fail-loud, WITHOUT killing selection.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void i01_bulkContractMismatch_isLoggedAndWithheld_selectionSurvives() throws Exception {
+ var body = viewsJs();
+ var fn = functionBody(body, "function initTable(");
+ assertTrue(fn.contains("bulkDef.contractVersion !== JUNEAU_BULK_CONTRACT_VERSION"), fn);
+ assertTrue(fn.contains("bulk mutation withheld"), fn);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 5014699b3a..dfd2af8b78 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -248,7 +248,7 @@ class ViewsMixin_Serving_Test extends TestBase {
@Test void d01_contractVersion_equalsViewDefContractVersion() {
assertEquals(ViewDef.CONTRACT_VERSION, ViewsMixin.CONTRACT_VERSION);
- assertEquals("2", ViewsMixin.CONTRACT_VERSION);
+ assertEquals("3", ViewsMixin.CONTRACT_VERSION);
}
@Test void d02_viewsJs_bakesInContractVersionHandshake() throws Exception {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java
new file mode 100644
index 0000000000..fa0d0a2c30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/WritePermit_SelectionDef_BulkMutateDef_Test.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.lang.reflect.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@code TODO-428}: {@link WritePermit}, {@link SelectionDef}, and {@link BulkMutateDef} — the API-shape half
+ * of the separability guarantee (design-doc HIGH-5).
+ *
+ * <p>
+ * These are NOT prose assertions: {@link BulkMutateDef#create(WritePermit, SelectionDef)} is the ONLY way to obtain
+ * a {@link BulkMutateDef} (a private constructor + reflection below prove there is no other, narrower factory), and
+ * both parameters are non-null-checked at that single call site — so a bulk-mutate opt-in that omits a
+ * {@link WritePermit} cannot compile against any DIFFERENT, permit-less overload (there isn't one) and cannot
+ * construct one by passing {@code null} either (it throws).
+ */
+class WritePermit_SelectionDef_BulkMutateDef_Test extends TestBase {
+
+ //------------------------------------------------------------------------------------------------------------------
+ // WritePermit
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_forCapability_rejectsNullAndBlank() {
+ assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability(null));
+ assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability(""));
+ assertThrows(IllegalArgumentException.class, () -> WritePermit.forCapability(" "));
+ }
+
+ @Test void a02_forCapability_roundTripsTheCapability() {
+ var p = WritePermit.forCapability("incidents:ack");
+ assertEquals("incidents:ack", p.capability());
+ assertTrue(p.toString().contains("incidents:ack"), p.toString());
+ }
+
+ /** WritePermit has no public constructor - the factory is the only way in, mirroring WriteGuard's discipline. */
+ @Test void a03_noPublicConstructor() {
+ for (var c : WritePermit.class.getDeclaredConstructors())
+ assertFalse(Modifier.isPublic(c.getModifiers()), () -> "WritePermit must not expose a public constructor: " + c);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // SelectionDef
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_create_rejectsNullAndBlankRowIdField() {
+ assertThrows(IllegalArgumentException.class, () -> SelectionDef.create(null));
+ assertThrows(IllegalArgumentException.class, () -> SelectionDef.create(""));
+ assertThrows(IllegalArgumentException.class, () -> SelectionDef.create(" "));
+ }
+
+ @Test void b02_selectAll_defaultsTrue_andIsToggleable() {
+ var s = SelectionDef.create("id");
+ assertEquals("id", s.rowIdField());
+ assertTrue(s.selectAll());
+ assertFalse(s.selectAll(false).selectAll());
+ assertTrue(s.selectAll(true).selectAll());
+ }
+
+ @Test void b03_noPublicConstructor() {
+ for (var c : SelectionDef.class.getDeclaredConstructors())
+ assertFalse(Modifier.isPublic(c.getModifiers()), () -> "SelectionDef must not expose a public constructor: " + c);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // BulkMutateDef - the separability/compile-shape guarantee (HIGH-5)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_create_requiresBothPermitAndSelection() {
+ var permit = WritePermit.forCapability("cap");
+ var selection = SelectionDef.create("id");
+ assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(null, selection));
+ assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(permit, null));
+ assertThrows(IllegalArgumentException.class, () -> BulkMutateDef.create(null, null));
+ }
+
+ @Test void c02_create_succeedsWithBoth_andExposesBothBack() {
+ var permit = WritePermit.forCapability("cap");
+ var selection = SelectionDef.create("id");
+ var b = BulkMutateDef.create(permit, selection);
+ assertSame(permit, b.permit());
+ assertSame(selection, b.selection());
+ }
+
+ @Test void c03_actions_rejectsNullAndEmpty() {
+ var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id"));
+ assertThrows(IllegalArgumentException.class, () -> b.actions((RowAction[]) null));
+ assertThrows(IllegalArgumentException.class, b::actions);
+ }
+
+ @Test void c04_actions_roundTrips() {
+ var ack = RowAction.create("ack").label("Acknowledge").endpoint("servlet:/bulk/ack").method(RowAction.Method.POST);
+ var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id")).actions(ack);
+ assertEquals(1, b.actions.size());
+ assertSame(ack, b.actions.get(0));
+ }
+
+ /**
+ * The API-SHAPE proof (HIGH-5), not a prose claim: {@code create(...)} is the ONLY public factory on
+ * {@link BulkMutateDef} and its signature is EXACTLY {@code (WritePermit, SelectionDef)} - there is no
+ * narrower overload a caller could reach for that skips the permit. Combined with c01 (a null permit throws)
+ * and c05/c06 below (no public constructor, no setter for the permit/selection fields), this proves a
+ * bulk-mutate opt-in cannot be constructed without supplying a real {@link WritePermit} - by shape, not by
+ * javadoc sentence.
+ */
+ @Test void c05_onlyOneCreateOverload_andItsSignatureRequiresWritePermitFirst() {
+ var createMethods = Arrays.stream(BulkMutateDef.class.getMethods())
+ .filter(m -> m.getName().equals("create"))
+ .toList();
+ assertEquals(1, createMethods.size(), () -> "expected exactly one create(...) overload: " + createMethods);
+ var m = createMethods.get(0);
+ assertArrayEquals(new Class<?>[] { WritePermit.class, SelectionDef.class }, m.getParameterTypes(),
+ () -> "BulkMutateDef.create(...) must require (WritePermit, SelectionDef), in that order: " + m);
+ }
+
+ @Test void c06_noPublicConstructor() {
+ for (var c : BulkMutateDef.class.getDeclaredConstructors())
+ assertFalse(Modifier.isPublic(c.getModifiers()), () -> "BulkMutateDef must not expose a public constructor: " + c);
+ }
+
+ /**
+ * The permit/selection fields backing separability are NOT public mutable fields either - a caller cannot
+ * bypass the constructor-time requirement by building a bare instance (impossible; no public ctor) and later
+ * poking a permit in via reflection-free code.
+ */
+ @Test void c07_permitAndSelectionAreNotPublicSettableFields() {
+ for (var f : BulkMutateDef.class.getDeclaredFields()) {
+ if (f.getType() == WritePermit.class || f.getType() == SelectionDef.class)
+ assertFalse(Modifier.isPublic(f.getModifiers()), () -> "must not be a public field: " + f);
+ }
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Independent contract version + per-target result shape (no permit/selection leak onto the wire)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_ownContractVersion_independentOfViewAndActionResult() {
+ // A third, independently-versioned contract, alongside ViewDef's and ActionResult's - see BulkMutateDef's
+ // class javadoc; this test only pins that the constant exists and is its own string, not that it must
+ // differ numerically (an incidental "3" vs "1" tells us nothing) - the independence is structural
+ // (three separate constants, never aliased), which the ViewTable render tests further verify at the wire.
+ assertEquals("1", BulkMutateDef.CONTRACT_VERSION);
+ }
+
+ @Test void d02_serializedForm_carriesOnlyContractVersionAndActions_neverThePermitOrSelection() {
+ var ack = RowAction.create("ack").endpoint("servlet:/bulk/ack").method(RowAction.Method.POST);
+ var b = BulkMutateDef.create(WritePermit.forCapability("cap"), SelectionDef.create("id")).actions(ack);
+ var json = Json.of(b);
+ assertFalse(json.contains("cap"), () -> "the WritePermit's capability must never reach the wire: " + json);
+ assertFalse(json.toLowerCase().contains("rowidfield"), () -> "the SelectionDef must never reach the wire: " + json);
+ var parsed = Json.to(json, Map.class);
+ assertEquals(Set.of("contractVersion", "actions"), parsed.keySet(), json);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs
new file mode 100644
index 0000000000..9ffdb23ec5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/async-job.cjs
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * async-job.cjs - real-browser prober for the juneau-views.js async-job + SSE-streaming contract (TODO-425).
+ *
+ * Never runs in a default build. It is driven by AsyncJob_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ * Usage: node async-job.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, drives an async row action through its full lifecycle against
+ * a controllable FAKE EventSource (there is no server; the fixture is a file://): the start POST returns a job
+ * pointer (not a terminal result), the row picks up the DISTINCT data-juneau-job affordance (NOT
+ * data-juneau-inflight, so the table KEEPS polling - the load-bearing HIGH-9 fact), progress events update a
+ * visible banner painted with textContent, and the single terminal `result` event settles the row to
+ * success / cancelled / cancelled-after-effect, or a stream error settles it to a non-optimistic unknown. Cancel
+ * issues a fail-closed CSRF POST to the job's cancelUrl. Prints ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors modal-result.cjs / row-actions.cjs): this script only OBSERVES; every assertion
+ * lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page. Async: the settle path reads the response body via a promise. */
+const PROBE = async function () {
+ const NS = window.JuneauViews;
+ const init = NS && NS.init;
+ const out = { hasInit: !!init };
+ if (!init) return out;
+
+ out.actionResultContractVersion = init.ACTION_RESULT_CONTRACT_VERSION;
+ const V = init.ACTION_RESULT_CONTRACT_VERSION;
+ const tick = () => new Promise(r => setTimeout(r, 0));
+
+ function rendered(el) {
+ if (!el) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ }
+
+ // A minimal row with an actions cell and a trigger button (setRowJobRunning disables the trigger).
+ function makeRow(rowId) {
+ const table = document.createElement('table');
+ table.setAttribute('data-juneau-view', 'v');
+ table.setAttribute('data-juneau-csrf', 'tok-xyz'); // so the fail-closed cancel POST is armed
+ const tbody = document.createElement('tbody');
+ const tr = document.createElement('tr');
+ if (rowId != null) tr.setAttribute('data-juneau-row-id', rowId);
+ const td = document.createElement('td');
+ td.className = 'juneau-view-actions-cell';
+ const trigger = document.createElement('button');
+ trigger.className = 'juneau-view-action-trigger';
+ td.appendChild(trigger);
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ table.appendChild(tbody);
+ document.body.appendChild(table);
+ return { table: table, tr: tr, td: td, trigger: trigger };
+ }
+
+ // A fake fetch Response with a synchronous headers.get and an async text().
+ function resp(o) {
+ return {
+ ok: o.ok,
+ status: o.status,
+ headers: { get: n => (o.headers || {})[n] || null },
+ text: () => Promise.resolve(o.body != null ? o.body : '')
+ };
+ }
+
+ // A CONTROLLABLE fake EventSource: startJobStream opens one, and the prober emits progress/result/error on it.
+ // (There is no server; the fixture is a file:// page, so a real EventSource would just error immediately.)
+ const esInstances = [];
+ function FakeEventSource(url) {
+ this.url = url;
+ this.closed = false;
+ this._listeners = {};
+ esInstances.push(this);
+ }
+ FakeEventSource.prototype.addEventListener = function (type, fn) {
+ (this._listeners[type] = this._listeners[type] || []).push(fn);
+ };
+ FakeEventSource.prototype.close = function () { this.closed = true; };
+ FakeEventSource.prototype.emit = function (type, data) {
+ (this._listeners[type] || []).slice().forEach(fn => fn({ data: data }));
+ };
+ window.EventSource = FakeEventSource;
+
+ function lastEs() { return esInstances[esInstances.length - 1]; }
+ function jobBanner(dom) { return dom.td.querySelector('.juneau-view-job-progress'); }
+ function jobMsg(dom) {
+ const b = jobBanner(dom);
+ const m = b ? b.querySelector('.juneau-view-job-progress-msg') : null;
+ return m ? m.textContent : null;
+ }
+ function outcomeOf(dom) {
+ const b = dom.td.querySelector('.juneau-view-action-outcome');
+ return {
+ visible: rendered(b),
+ state: b ? b.getAttribute('data-state') : null,
+ role: b ? b.getAttribute('role') : null,
+ text: b ? b.textContent : null
+ };
+ }
+
+ // Start an async job by settling the start-POST response with a JOB POINTER (no `outcome` -> not a typed result).
+ async function startJob(rowId) {
+ const dom = makeRow(rowId || 'INC-1');
+ init.setRowInFlight(dom.tr, true); // the sync submit set the in-flight marker; settle must clear it FIRST
+ const jobPointer = { jobId: 'cap-' + rowId, streamUrl: '/juneau-jobs/cap/stream', cancelUrl: '/juneau-jobs/cap/cancel' };
+ init.settleActionResponse(resp({ ok: true, status: 200, body: JSON.stringify(jobPointer) }),
+ { id: 'ack', onSuccess: 'mergeRow' }, dom.table, dom.tr,
+ { mergeRow: function (tr, row) { dom._merged = row; } });
+ await tick(); await tick(); await tick();
+ return dom;
+ }
+
+ // ---- Scenario 1: a running job uses the DISTINCT marker and does NOT freeze polling (HIGH-9) ----
+ {
+ const dom = await startJob('INC-1');
+ out.running = {
+ hasJobMarker: dom.tr.hasAttribute('data-juneau-job'),
+ hasInflightMarker: dom.tr.hasAttribute('data-juneau-inflight'),
+ // The load-bearing HIGH-9 fact: polling is NOT frozen while the job runs.
+ pollingFrozen: init.hasInFlightRow(dom.table),
+ progressVisible: rendered(jobBanner(dom)),
+ progressText: jobMsg(dom),
+ triggerDisabled: !!dom.trigger.disabled,
+ esOpened: esInstances.length > 0,
+ esUrl: lastEs() ? lastEs().url : null
+ };
+ // A progress event updates the live banner (still not frozen, still no outcome yet).
+ lastEs().emit('progress', 'step 2 of 3');
+ await tick();
+ out.progress = {
+ text: jobMsg(dom),
+ pollingFrozenDuringProgress: init.hasInFlightRow(dom.table),
+ outcomeYet: !!dom.td.querySelector('.juneau-view-action-outcome')
+ };
+ // The terminal `result` event settles the row to success: banner cleared, marker removed, trigger re-enabled.
+ lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-1', status: 'ack' } }));
+ await tick(); await tick();
+ out.settledSuccess = {
+ esClosed: lastEs().closed,
+ jobMarkerCleared: !dom.tr.hasAttribute('data-juneau-job'),
+ progressCleared: !jobBanner(dom),
+ triggerReEnabled: !dom.trigger.disabled,
+ pollingFrozen: init.hasInFlightRow(dom.table),
+ mergedRow: dom._merged,
+ outcome: outcomeOf(dom)
+ };
+ }
+
+ // ---- Scenario 2: Cancel issues a fail-closed CSRF POST; the SERVER-authoritative outcome arrives over SSE ----
+ {
+ const dom = await startJob('INC-2');
+ const fetchCalls = [];
+ const realFetch = window.fetch;
+ window.fetch = function (url, opts) { fetchCalls.push({ url: url, opts: opts }); return Promise.resolve(resp({ ok: true, status: 200, body: '' })); };
+ const cancelBtn = jobBanner(dom).querySelector('.juneau-view-job-cancel');
+ out.cancel = { cancelBtnVisible: rendered(cancelBtn) };
+ cancelBtn.click();
+ await tick(); await tick();
+ window.fetch = realFetch;
+ out.cancel.postIssued = fetchCalls.length > 0;
+ if (fetchCalls.length > 0) {
+ out.cancel.url = fetchCalls[0].url;
+ out.cancel.method = fetchCalls[0].opts.method;
+ out.cancel.csrfHeader = (fetchCalls[0].opts.headers || {})['X-Csrf-Token'];
+ }
+ // The authoritative terminal outcome still arrives over the stream: cancelled.
+ lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'cancelled' }));
+ await tick(); await tick();
+ out.cancel.outcome = outcomeOf(dom);
+ out.cancel.jobMarkerCleared = !dom.tr.hasAttribute('data-juneau-job');
+ }
+
+ // ---- Scenario 3: cancelled-after-effect is a DISTINCT terminal outcome (Q4), rendered on its own ----
+ {
+ const dom = await startJob('INC-3');
+ lastEs().emit('result', JSON.stringify({ contractVersion: V, outcome: 'cancelled-after-effect' }));
+ await tick(); await tick();
+ out.cancelledAfterEffect = outcomeOf(dom);
+ }
+
+ // ---- Scenario 4: a stream error is itself a non-optimistic terminal outcome; polling was never frozen ----
+ {
+ const dom = await startJob('INC-4');
+ lastEs().emit('error', {});
+ await tick(); await tick();
+ out.streamError = {
+ outcome: outcomeOf(dom),
+ jobMarkerCleared: !dom.tr.hasAttribute('data-juneau-job'),
+ pollingFrozen: init.hasInFlightRow(dom.table)
+ };
+ }
+
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node async-job.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+
+ const url = 'file://' + path.resolve(fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto(url);
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(e => {
+ process.stderr.write(String((e && e.stack) || e) + '\n');
+ process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
new file mode 100644
index 0000000000..fef9bb651f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * modal-result.cjs - real-browser prober for the juneau-views.js declarative-modal + typed-action-result +
+ * in-flight-row contract (TODO-416/417).
+ *
+ * Never runs in a default build. It is driven by ModalResult_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ * Usage: node modal-result.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, exercises the modal overlay, the typed-field confirmation
+ * (painted with textContent, so an HTML-shaped field value must NOT become an element), every settled outcome
+ * (success/failure/refusal/unknown) plus a non-2xx transport refusal, and the in-flight marker lifecycle. Prints
+ * ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors row-actions.cjs): this script only OBSERVES; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page. Async: the settle/dialog paths read the response body via promises. */
+const PROBE = async function () {
+ const NS = window.JuneauViews;
+ const init = NS && NS.init;
+ const out = { hasInit: !!init };
+ if (!init) return out;
+
+ out.actionResultContractVersion = init.ACTION_RESULT_CONTRACT_VERSION;
+
+ const tick = () => new Promise(r => setTimeout(r, 0));
+
+ function rendered(el) {
+ if (!el) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ }
+
+ // A minimal row with an actions cell and a trigger button (setRowInFlight disables the trigger).
+ function makeRow(rowId) {
+ const table = document.createElement('table');
+ table.setAttribute('data-juneau-view', 'v');
+ const tbody = document.createElement('tbody');
+ const tr = document.createElement('tr');
+ if (rowId != null) tr.setAttribute('data-juneau-row-id', rowId);
+ const td = document.createElement('td');
+ td.className = 'juneau-view-actions-cell';
+ const trigger = document.createElement('button');
+ trigger.className = 'juneau-view-action-trigger';
+ td.appendChild(trigger);
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ table.appendChild(tbody);
+ document.body.appendChild(table);
+ return { table: table, tr: tr, td: td, trigger: trigger };
+ }
+
+ // A fake fetch Response with a synchronous headers.get and an async text().
+ function resp(o) {
+ return {
+ ok: o.ok,
+ status: o.status,
+ headers: { get: n => (o.headers || {})[n] || null },
+ text: () => Promise.resolve(o.body != null ? o.body : '')
+ };
+ }
+
+ function outcomeOf(dom) {
+ const b = dom.td.querySelector('.juneau-view-action-outcome');
+ return {
+ visible: rendered(b),
+ state: b ? b.getAttribute('data-state') : null,
+ role: b ? b.getAttribute('role') : null,
+ text: b ? b.textContent : null,
+ inflight: dom.tr.hasAttribute('data-juneau-inflight'),
+ triggerDisabled: !!dom.trigger.disabled,
+ pollingFrozen: init.hasInFlightRow(dom.table)
+ };
+ }
+
+ // Drive settleActionResponse against a fabricated response, starting from an in-flight row.
+ async function settle(r, action, ctx) {
+ const dom = makeRow('INC-1');
+ init.setRowInFlight(dom.tr, true);
+ const before = outcomeOf(dom);
+ init.settleActionResponse(r, action || {}, dom.table, dom.tr, ctx || {});
+ await tick(); await tick(); await tick();
+ const after = outcomeOf(dom);
+ after.wasInflightBefore = before.inflight;
+ return after;
+ }
+
+ const V = init.ACTION_RESULT_CONTRACT_VERSION;
+
+ // ---- All four typed outcomes on a 2xx typed result ----
+ out.success = await settle(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-1', status: 'ack' } }) }),
+ { id: 'ack', onSuccess: 'mergeRow' },
+ { mergeRow: function (tr, row) { out.mergedRow = row; } });
+
+ out.failure = await settle(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'failure', message: 'nope' }) }));
+
+ out.refusal = await settle(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'refusal', refusalCode: 'write-guard:not-armed' }) }));
+
+ out.unknown = await settle(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'unknown' }) }));
+
+ // ---- Contract-version mismatch on a 2xx -> visible UNKNOWN (never silently misread) ----
+ out.contractMismatch = await settle(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: '999', outcome: 'success', row: {} }) }));
+
+ // ---- Non-2xx transport refusal: read X-Loopback-Boundary + {reason,message} envelope ----
+ out.transport403 = await settle(resp({ ok: false, status: 403,
+ headers: { 'X-Loopback-Boundary': 'CSRF_TOKEN_MISSING' },
+ body: JSON.stringify({ reason: 'CSRF_TOKEN_MISSING', message: 'missing token' }) }));
+
+ out.transport421 = await settle(resp({ ok: false, status: 421, body: '' }));
+
+ // ---- Merge re-render marked the row ----
+ {
+ const dom = makeRow('INC-9');
+ const ctx = { mergeRow: function (tr, row) { ctx._row = row; } };
+ init.settleActionResponse(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'success', row: { id: 'INC-9', status: 's' } }) }),
+ { id: 'ack', onSuccess: 'mergeRow' }, dom.table, dom.tr, ctx);
+ await tick(); await tick();
+ out.mergeMarkedRow = dom.tr.getAttribute('data-juneau-row-merged');
+ out.mergeCtxRow = ctx._row;
+ }
+
+ // ---- Modal overlay + typed-field confirmation via textContent (XSS-safe) ----
+ {
+ const dom = makeRow('INC-1');
+ const evil = '<img src=x onerror=alert(1)>';
+ const modal = {
+ title: 'Acknowledge this incident?',
+ fields: [
+ { label: 'Incident', value: 'INC-1' },
+ { label: 'Title', value: evil }
+ ],
+ idempotencyKey: 'key-abc'
+ };
+ const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST', present: 'dialog' };
+ const ui = init.showActionDialog(modal, action, dom.table, dom.tr, {});
+ await tick();
+ const backdrop = document.querySelector('.juneau-view-dialog-backdrop');
+ const fieldsEl = backdrop ? backdrop.querySelector('.juneau-view-dialog-fields') : null;
+ const dds = fieldsEl ? Array.from(fieldsEl.querySelectorAll('dd')) : [];
+ out.modal = {
+ backdropVisible: rendered(backdrop),
+ title: backdrop ? (backdrop.querySelector('.juneau-view-dialog-title') || {}).textContent : null,
+ fieldCount: dds.length,
+ evilFieldText: dds.length > 1 ? dds[1].textContent : null,
+ // The XSS proof: the HTML-shaped value must NOT have become an <img> element anywhere in the dialog.
+ injectedImgCount: backdrop ? backdrop.querySelectorAll('img').length : -1
+ };
+ // Confirm-button click submits with the idempotency key + target id (stub fetch to capture the body).
+ const fetchCalls = [];
+ const realFetch = window.fetch;
+ window.fetch = function (url, opts) {
+ fetchCalls.push({ url: url, opts: opts });
+ return Promise.resolve(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V, outcome: 'success' }) }));
+ };
+ dom.table.setAttribute('data-juneau-csrf', 'tok-xyz');
+ ui.confirmBtn.click();
+ await tick(); await tick();
+ window.fetch = realFetch;
+ out.modal.submitIssued = fetchCalls.length > 0;
+ if (fetchCalls.length > 0) {
+ out.modal.submitBody = fetchCalls[0].opts.body;
+ out.modal.backdropClosedAfterConfirm = !document.querySelector('.juneau-view-dialog-backdrop');
+ }
+ }
+
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node modal-result.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+
+ const url = 'file://' + path.resolve(fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto(url);
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(e => {
+ process.stderr.write(String((e && e.stack) || e) + '\n');
+ process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs
new file mode 100644
index 0000000000..16e424db35
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-actions.cjs
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * row-actions.cjs - real-browser prober for the juneau-views.js row-action + fail-closed CSRF contract (TODO-415).
+ *
+ * Never runs in a default build. It is driven by RowActionCsrf_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ * Usage: node row-actions.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, exercises the runtime's row-action pipeline and its
+ * fail-closed CSRF submit via the real DOM and a stubbed window.fetch. Prints ONE JSON object to stdout.
+ *
+ * DIVISION OF LABOUR (mirrors panel-visibility.cjs): this script only OBSERVES; every assertion lives in the Java
+ * test. It reports facts (was fetch issued, with what method/headers/body; did a visible refusal banner appear)
+ * and lets JUnit decide pass/fail, so the expectations live next to the runtime they constrain.
+ *
+ * WHY A REAL BROWSER: the fail-closed refusal is a user-visible outcome (a rendered banner and a NOT-sent request);
+ * measuring "a request was suppressed and a banner became visible" is exactly what a browser, and not a substring
+ * assertion, can show.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/*
+ * Runs inside the page. window.JuneauViews.init is populated by the real juneau-views.js the fixture loaded.
+ * Drives the pure request builder AND the DOM menu/submit path against a stubbed fetch, returning a plain report.
+ */
+const PROBE = function () {
+ const NS = window.JuneauViews;
+ const init = NS && NS.init;
+ const out = { hasInit: !!init };
+ if (!init) return out;
+
+ out.contractVersion = NS.CONTRACT_VERSION;
+ out.defaultCsrfHeader = init.DEFAULT_CSRF_HEADER;
+
+ // Pure request builder: whitespace, absent, safe-method all refuse; a valid token yields a JSON request.
+ const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST' };
+ out.reqBlankWhitespace = init.buildActionRequest(action, ' ', null);
+ out.reqAbsent = init.buildActionRequest(action, null, null);
+ out.reqSafeMethod = init.buildActionRequest({ id: 'g', endpoint: '/x', method: 'GET' }, 'tok', null);
+ out.reqValid = init.buildActionRequest(action, 'tok-123', null);
+
+ // Behavioural DOM path: build a table+row carrying a token attribute, open the menu, click the item, and
+ // record whether fetch was issued and whether a visible refusal banner rendered.
+ const fetchCalls = [];
+ const realFetch = window.fetch;
+ window.fetch = function (url, opts) {
+ fetchCalls.push({ url: url, opts: opts });
+ return Promise.resolve({ ok: true }); // 415 does not act on the body; a resolved ok is enough here
+ };
+
+ function makeTable(tokenValue) {
+ const table = document.createElement('table');
+ table.setAttribute('data-juneau-view', 'v');
+ if (tokenValue != null) table.setAttribute('data-juneau-csrf', tokenValue);
+ const tbody = document.createElement('tbody');
+ const tr = document.createElement('tr');
+ const td = document.createElement('td');
+ td.className = 'juneau-view-actions-cell';
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ table.appendChild(tbody);
+ document.body.appendChild(table);
+ return { table: table, tr: tr, td: td };
+ }
+
+ function rendered(el) {
+ if (!el) return false;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ }
+
+ // Case 1: BLANK (whitespace) token -> visible refusal, NO fetch.
+ const before1 = fetchCalls.length;
+ const dom1 = makeTable(' ');
+ const menu1 = init.buildRowActionMenu({ rowActions: [action] }, dom1.table, dom1.tr, {});
+ dom1.td.appendChild(menu1);
+ menu1.querySelector('.juneau-view-action-item').click();
+ out.blankTokenFetchIssued = fetchCalls.length > before1;
+ const banner1 = dom1.td.querySelector('.juneau-view-action-refusal');
+ out.blankTokenRefusalVisible = rendered(banner1);
+ out.blankTokenRefusalText = banner1 ? banner1.textContent : null;
+
+ // Case 2: VALID token -> fetch issued with POST + JSON content type + the CSRF header, NO refusal banner.
+ const before2 = fetchCalls.length;
+ const dom2 = makeTable('tok-123');
+ const menu2 = init.buildRowActionMenu({ rowActions: [action] }, dom2.table, dom2.tr, {});
+ dom2.td.appendChild(menu2);
+ menu2.querySelector('.juneau-view-action-item').click();
+ const call = fetchCalls[fetchCalls.length - 1];
+ out.validTokenFetchIssued = fetchCalls.length > before2;
+ if (out.validTokenFetchIssued) {
+ out.validTokenUrl = call.url;
+ out.validTokenMethod = call.opts.method;
+ out.validTokenContentType = call.opts.headers['Content-Type'];
+ out.validTokenCsrfHeader = call.opts.headers[init.DEFAULT_CSRF_HEADER];
+ out.validTokenBody = call.opts.body;
+ }
+ out.validTokenRefusalVisible = rendered(dom2.td.querySelector('.juneau-view-action-refusal'));
+
+ window.fetch = realFetch;
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node row-actions.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+
+ const url = 'file://' + path.resolve(fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto(url);
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(e => {
+ process.stderr.write(String((e && e.stack) || e) + '\n');
+ process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs
new file mode 100644
index 0000000000..27a93c3523
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-selection-bulk.cjs
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * row-selection-bulk.cjs - real-browser prober for the juneau-views.js row-selection + bulk-mutation contract
+ * (TODO-428).
+ *
+ * Never runs in a default build. It is driven by RowSelectionBulk_BrowserTest, which itself only runs under
+ * `mvn -Pjs-tests`; see that class's javadoc and the profile comment in this module's pom.xml.
+ *
+ * Usage: node row-selection-bulk.cjs <page.html>
+ *
+ * Loads <page.html> - a self-contained fixture the Java test writes from the REAL served juneau-views.js - in
+ * headless Chromium, then, entirely inside the page, drives the exposed selection/bulk helpers directly (no
+ * jQuery/DataTables is bundled by this module, so - mirroring row-actions.cjs and modal-result.cjs - this prober
+ * never boots a real DataTable; it exercises initSelection/executeBulkAction/etc. against a fabricated DOM +
+ * fetch, which is exactly the same binding surface initTable itself calls into). Prints ONE JSON object to
+ * stdout.
+ *
+ * DIVISION OF LABOUR (mirrors row-actions.cjs / modal-result.cjs): this script only OBSERVES; every assertion
+ * lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+/* Runs inside the page. Async: the bulk-settle path reads response bodies via promises. */
+const PROBE = async function () {
+ const NS = window.JuneauViews;
+ const init = NS && NS.init;
+ const out = { hasInit: !!init };
+ if (!init) return out;
+
+ out.bulkContractVersion = NS.BULK_CONTRACT_VERSION;
+
+ const tick = () => new Promise(r => setTimeout(r, 0));
+
+ // Builds a table with one row per id in `rowIds`, each carrying the stable ROW_ID_ATTR (never a DOM index),
+ // a selection-cell checkbox, and an actions-cell + trigger (for the bulk-settle path to render into).
+ function makeTable(rowIds, opts) {
+ opts = opts || {};
+ const table = document.createElement('table');
+ table.setAttribute('data-juneau-view', 'v');
+ if (opts.select) table.setAttribute(init.SELECT_ATTR, '1');
+ if (opts.select && opts.selectAll !== false) table.setAttribute(init.SELECT_ALL_ATTR, '1');
+ if (opts.rowIdField) table.setAttribute(init.ROW_ID_FIELD_ATTR, opts.rowIdField);
+ if (opts.bulk) table.setAttribute(init.BULK_ATTR, '1');
+ if (opts.csrf) table.setAttribute('data-juneau-csrf', opts.csrf);
+
+ const thead = document.createElement('thead');
+ const headRow = document.createElement('tr');
+ const selectTh = document.createElement('th');
+ selectTh.className = 'juneau-view-select-th';
+ headRow.appendChild(selectTh);
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement('tbody');
+ const trs = {};
+ rowIds.forEach(function (id) {
+ const tr = document.createElement('tr');
+ tr.setAttribute(init.ROW_ID_ATTR, id);
+ const selectTd = document.createElement('td');
+ selectTd.innerHTML = init.selectionCellMarkup(false);
+ tr.appendChild(selectTd);
+ const actionsTd = document.createElement('td');
+ actionsTd.className = 'juneau-view-actions-cell';
+ const trigger = document.createElement('button');
+ trigger.className = 'juneau-view-action-trigger';
+ actionsTd.appendChild(trigger);
+ tr.appendChild(actionsTd);
+ tbody.appendChild(tr);
+ trs[id] = tr;
+ });
+ table.appendChild(tbody);
+ document.body.appendChild(table);
+ return { table: table, trs: trs, selectTh: selectTh };
+ }
+
+ // A minimal fake DataTables instance exposing only `.on(event, cb)` - just enough for initSelection's
+ // draw.dt-driven off-screen-id-drop wiring; `.fire` lets the probe simulate a poll/sort/page tick.
+ function fakeDt() {
+ const handlers = {};
+ return {
+ on: function (evt, cb) { (handlers[evt] = handlers[evt] || []).push(cb); },
+ fire: function (evt) { (handlers[evt] || []).forEach(function (cb) { cb(); }); }
+ };
+ }
+
+ function dispatchChange(el) {
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ // ---- a) per-row selection + select-all (MED-11: identity is the stable id, never a DOM index) ----
+ {
+ const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id' });
+ const selectionState = { selected: new Set(), rowIdField: 'id' };
+ init.initSelection(dom.table, fakeDt(), selectionState, {});
+
+ function check(id, checked) {
+ const cb = dom.trs[id].querySelector('.juneau-view-select-checkbox');
+ cb.checked = checked;
+ dispatchChange(cb);
+ }
+
+ check('1', true);
+ check('2', true);
+ out.afterTwoChecked = Array.from(selectionState.selected).sort();
+
+ const allCb = dom.selectTh.querySelector('.juneau-view-select-all-checkbox');
+ out.hasSelectAllCheckbox = !!allCb;
+ allCb.checked = true;
+ dispatchChange(allCb);
+ out.afterSelectAll = Array.from(selectionState.selected).sort();
+
+ allCb.checked = false;
+ dispatchChange(allCb);
+ out.afterDeselectAll = Array.from(selectionState.selected).sort();
+ }
+
+ // ---- b) off-screen-id-drop persistence rule (MED-11/Q2): a poll/sort/page draw prunes ids no longer present ----
+ {
+ const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id' });
+ const selectionState = { selected: new Set(['1', '2', '3']), rowIdField: 'id' };
+ const dt = fakeDt();
+ init.initSelection(dom.table, dt, selectionState, {});
+
+ dom.trs['3'].parentNode.removeChild(dom.trs['3']); // row '3' left the current draw
+ dt.fire('draw.dt');
+
+ out.selectedAfterOffScreenDraw = Array.from(selectionState.selected).sort();
+ }
+
+ // ---- c) two INDEPENDENT opt-ins (HIGH-5): a selection-only (export) table never carries the bulk marker ----
+ {
+ const selectOnly = makeTable(['1'], { select: true, rowIdField: 'id', bulk: false });
+ const withBulk = makeTable(['1'], { select: true, rowIdField: 'id', bulk: true });
+ out.selectOnlyHasSelection = init.hasSelection(selectOnly.table);
+ out.selectOnlyHasBulk = init.hasBulk(selectOnly.table);
+ out.withBulkHasSelection = init.hasSelection(withBulk.table);
+ out.withBulkHasBulk = init.hasBulk(withBulk.table);
+ }
+
+ // ---- d) bulk = N INDEPENDENT per-row writes; per-target typed result; per-row in-flight marker ----
+ {
+ const dom = makeTable(['1', '2', '3'], { select: true, rowIdField: 'id', bulk: true, csrf: 'tok-xyz' });
+ // '9' has no corresponding row - simulates an id that went off-screen between the click and this run;
+ // executeBulkAction must silently skip it rather than target a row that no longer exists.
+ const selectionState = { selected: new Set(['1', '2', '3', '9']), rowIdField: 'id' };
+
+ const fetchCalls = [];
+ const inflightAtFetchTime = {};
+ const realFetch = window.fetch;
+ window.fetch = function (url, opts) {
+ const body = JSON.parse(opts.body);
+ fetchCalls.push({ url: url, body: body });
+ inflightAtFetchTime[body.targetId] = dom.trs[body.targetId].hasAttribute('data-juneau-inflight');
+ let respBody;
+ if (body.targetId === '1') respBody = { contractVersion: '1', outcome: 'success' };
+ else if (body.targetId === '2') respBody = { contractVersion: '1', outcome: 'failure', message: 'nope' };
+ else respBody = { contractVersion: '1', outcome: 'refusal', refusalCode: 'write-guard:not-armed' };
+ return Promise.resolve({
+ ok: true, status: 200,
+ headers: { get: function () { return null; } },
+ text: function () { return Promise.resolve(JSON.stringify(respBody)); }
+ });
+ };
+
+ const action = { id: 'ack', label: 'Acknowledge', endpoint: '/x/ack', method: 'POST' };
+ init.executeBulkAction(action, dom.table, {}, selectionState);
+ await tick(); await tick(); await tick(); await tick(); await tick();
+ window.fetch = realFetch;
+
+ function outcomeOf(id) {
+ const cell = dom.trs[id].querySelector('.juneau-view-action-outcome');
+ return cell ? { state: cell.getAttribute('data-state'), text: cell.textContent } : null;
+ }
+
+ out.bulk = {
+ fetchCount: fetchCalls.length,
+ targetIds: fetchCalls.map(function (c) { return c.body.targetId; }).sort(),
+ actionIds: fetchCalls.map(function (c) { return c.body.action; }),
+ inflightAtFetchTime: inflightAtFetchTime,
+ inflightAfter: {
+ '1': dom.trs['1'].hasAttribute('data-juneau-inflight'),
+ '2': dom.trs['2'].hasAttribute('data-juneau-inflight'),
+ '3': dom.trs['3'].hasAttribute('data-juneau-inflight')
+ },
+ outcome1: outcomeOf('1'),
+ outcome2: outcomeOf('2'),
+ outcome3: outcomeOf('3')
+ };
+ }
+
+ // ---- e) the bulk toolbar reflects the live selection count and gates its buttons on it ----
+ {
+ const dom = makeTable(['1', '2'], { select: true, rowIdField: 'id', bulk: true });
+ const selectionState = { selected: new Set(), rowIdField: 'id' };
+ const bulkDef = { contractVersion: NS.BULK_CONTRACT_VERSION, actions: [{ id: 'ack', label: 'Acknowledge' }] };
+ const toolbar = init.buildBulkToolbar(bulkDef, dom.table, {}, selectionState);
+ const btn = toolbar.el.querySelector('.juneau-view-bulk-action-btn');
+ out.toolbar = { disabledInitially: btn.disabled };
+ toolbar.refresh(2);
+ out.toolbar.disabledWithSelection = btn.disabled;
+ out.toolbar.countTextWithSelection = toolbar.el.querySelector('.juneau-view-bulk-count').textContent;
+ toolbar.refresh(0);
+ out.toolbar.disabledAfterCleared = btn.disabled;
+ }
+
+ // ---- f) the bulk sidecar is read + contract-checked at runtime (R2: independent of VIEW_META) ----
+ {
+ const id = 'v-sidecar';
+ const sidecar = document.createElement('script');
+ sidecar.type = 'application/json';
+ sidecar.id = init.BULK_SIDECAR_ID_PREFIX + id;
+ sidecar.textContent = JSON.stringify({ contractVersion: NS.BULK_CONTRACT_VERSION, actions: [{ id: 'a' }] });
+ document.body.appendChild(sidecar);
+ const read = init.readBulkDef(id);
+ out.bulkSidecar = {
+ contractVersion: read ? read.contractVersion : null,
+ actionCount: read && read.actions ? read.actions.length : -1,
+ missingSidecarReturnsNull: init.readBulkDef('does-not-exist') === null
+ };
+ }
+
+ return out;
+};
+
+(async () => {
+ const [fixture] = process.argv.slice(2);
+ if (!fixture) {
+ process.stderr.write('usage: node row-selection-bulk.cjs <page.html>\n');
+ process.exit(2);
+ }
+ if (!fs.existsSync(fixture))
+ throw new Error('fixture not found: ' + fixture);
+
+ const url = 'file://' + path.resolve(fixture);
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const failures = [];
+ page.on('pageerror', e => failures.push(String(e)));
+ page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ await page.goto(url);
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ const report = await page.evaluate(PROBE);
+ report.jsFailures = failures.slice();
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ } finally {
+ await browser.close();
+ }
+})().catch(e => {
+ process.stderr.write(String((e && e.stack) || e) + '\n');
+ process.exit(1);
+});