fix(security): bound untrusted wire decoding
req: security/005
This commit is contained in:
@@ -117,12 +117,48 @@ async function openLog() {
|
||||
return requestResult(request);
|
||||
}
|
||||
|
||||
export function validateQueuedCommand(command) {
|
||||
if (!command || Object.getPrototypeOf(command) !== Object.prototype) {
|
||||
throw new TypeError("queued command must be an object");
|
||||
}
|
||||
if (command.schemaVersion !== COMMAND_SCHEMA) {
|
||||
throw new RangeError(`unsupported queued command schema version ${command.schemaVersion}`);
|
||||
}
|
||||
const boundedString = (field, maximum) => {
|
||||
const value = command[field];
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum) {
|
||||
throw new TypeError(`queued command ${field} is invalid`);
|
||||
}
|
||||
};
|
||||
boundedString("id", 256);
|
||||
boundedString("accountPartition", 128);
|
||||
boundedString("actor", 128);
|
||||
boundedString("session", 128);
|
||||
boundedString("cardId", 128);
|
||||
if (!Number.isSafeInteger(command.causal) || command.causal < 1) {
|
||||
throw new TypeError("queued command causal is invalid");
|
||||
}
|
||||
const queuedAt = command.queuedAt === undefined ? 0 : command.queuedAt;
|
||||
if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) {
|
||||
throw new TypeError("queued command queuedAt is invalid");
|
||||
}
|
||||
if (command.kind !== "reorder_card") throw new TypeError(`unknown queued command kind ${command.kind}`);
|
||||
if (command.targetColumn !== "done") throw new TypeError(`unknown queued command target ${command.targetColumn}`);
|
||||
if (!["click", "drop", "keydown"].includes(command.eventKind)) {
|
||||
throw new TypeError(`unknown queued command event kind ${command.eventKind}`);
|
||||
}
|
||||
if (command.key !== null && (typeof command.key !== "string" || command.key.length > 64)) {
|
||||
throw new TypeError("queued command key is invalid");
|
||||
}
|
||||
return command.queuedAt === undefined ? { ...command, queuedAt } : command;
|
||||
}
|
||||
|
||||
async function pendingCommands(database) {
|
||||
const transaction = database.transaction(COMMANDS, "readonly");
|
||||
const done = transactionDone(transaction);
|
||||
const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition));
|
||||
await done;
|
||||
return commands.sort((left, right) => left.causal - right.causal);
|
||||
return commands.map(validateQueuedCommand).sort((left, right) => left.causal - right.causal);
|
||||
}
|
||||
|
||||
async function removePendingCommand(database, commandId) {
|
||||
|
||||
@@ -2077,6 +2077,125 @@ async fn keep_local_retry_preserves_conflicted_command_and_suffix_order() -> Web
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adversarial_wire_inputs_are_rejected_before_partial_application() -> WebDriverResult<()> {
|
||||
// test req: security/005
|
||||
let app_port = available_port();
|
||||
let app_addr = format!("127.0.0.1:{app_port}");
|
||||
let mut app_command = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example"));
|
||||
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
|
||||
let _app = TestProcess::start(app_command, "hemx-kanban", &app_addr, STARTUP_TIMEOUT)
|
||||
.expect("start hemx-kanban");
|
||||
|
||||
let webdriver_port = available_port();
|
||||
let webdriver_addr = format!("127.0.0.1:{webdriver_port}");
|
||||
let mut webdriver = Command::new("geckodriver");
|
||||
webdriver.arg("--port").arg(webdriver_port.to_string());
|
||||
let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT)
|
||||
.expect("start ready geckodriver");
|
||||
let mut caps = DesiredCapabilities::firefox();
|
||||
caps.set_headless()?;
|
||||
let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?;
|
||||
|
||||
let result = async {
|
||||
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
|
||||
wait_until(
|
||||
&driver,
|
||||
"const root = document.querySelector('[data-kanban-sync]'); return window.hemx && root?.getAttribute('data-sync-phase') === 'idle'",
|
||||
)
|
||||
.await?;
|
||||
let proof = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
(async () => {
|
||||
const root = document.querySelector('[data-kanban-sync]');
|
||||
const before = root.outerHTML;
|
||||
const validEmpty = new Uint8Array([
|
||||
72, 69, 77, 88, 1, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
]);
|
||||
const invalidKind = new Uint8Array([...validEmpty.slice(0, 16), 1, 0, 0, 0, 255]);
|
||||
const unknownVersion = validEmpty.slice();
|
||||
unknownVersion[4] = 99;
|
||||
const cases = [
|
||||
["malformed", new Uint8Array([0, 1, 2, 3])],
|
||||
["truncated", validEmpty.slice(0, -1)],
|
||||
["trailing", new Uint8Array([...validEmpty, 1])],
|
||||
["unknown-version", unknownVersion],
|
||||
["invalid-kind", invalidKind],
|
||||
["oversized", new Uint8Array(1024 * 1024 + 1)],
|
||||
];
|
||||
const batchErrors = Object.fromEntries(cases.map(([name, bytes]) => {
|
||||
try {
|
||||
window.hemx.decodeBatch(bytes.buffer);
|
||||
return [name, null];
|
||||
} catch (error) {
|
||||
return [name, String(error)];
|
||||
}
|
||||
}));
|
||||
const stateErrors = {};
|
||||
for (const [name, encoded] of [
|
||||
["truncated", "AQ"],
|
||||
["trailing", "AAA"],
|
||||
["oversized", "A".repeat(Math.ceil((1024 * 1024) * 4 / 3) + 8)],
|
||||
]) {
|
||||
try {
|
||||
window.hemx.decodeAtomState(encoded);
|
||||
stateErrors[name] = null;
|
||||
} catch (error) {
|
||||
stateErrors[name] = String(error);
|
||||
}
|
||||
}
|
||||
const { validateQueuedCommand } = await import('/sync.js');
|
||||
const command = {
|
||||
id: 'actor:1', schemaVersion: 2, accountPartition: 'demo:demo', actor: 'actor',
|
||||
session: 'session', causal: 1, queuedAt: 1, kind: 'reorder_card', cardId: '1',
|
||||
targetColumn: 'done', eventKind: 'click', key: null,
|
||||
};
|
||||
const commandErrors = {};
|
||||
for (const [name, candidate] of [
|
||||
["unknown-version", { ...command, schemaVersion: 99 }],
|
||||
["invalid-kind", { ...command, kind: 'execute_script' }],
|
||||
["oversized-id", { ...command, id: 'x'.repeat(257) }],
|
||||
]) {
|
||||
try {
|
||||
validateQueuedCommand(candidate);
|
||||
commandErrors[name] = null;
|
||||
} catch (error) {
|
||||
commandErrors[name] = String(error);
|
||||
}
|
||||
}
|
||||
done({
|
||||
batchErrors,
|
||||
stateErrors,
|
||||
commandErrors,
|
||||
unchanged: before === root.outerHTML,
|
||||
});
|
||||
})().catch((error) => done({ error: String(error), stack: error?.stack }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert!(proof["error"].is_null(), "adversarial proof failed: {proof}");
|
||||
for group in ["batchErrors", "stateErrors", "commandErrors"] {
|
||||
let errors = proof[group].as_object().expect("error group");
|
||||
assert!(
|
||||
errors.values().all(|error| error.as_str().is_some_and(|message| !message.is_empty())),
|
||||
"{group} accepted an adversarial input: {proof}"
|
||||
);
|
||||
}
|
||||
assert_eq!(proof["unchanged"], true, "input rejection mutated the UI: {proof}");
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let quit = driver.quit().await;
|
||||
result.and(quit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_requests_timeout_and_cancel_on_pagehide() -> WebDriverResult<()> {
|
||||
// test req: operations/003
|
||||
|
||||
+72
-18
@@ -406,11 +406,16 @@
|
||||
}
|
||||
|
||||
function applyBatch(buffer, root) {
|
||||
const batch = decodeBatch(buffer);
|
||||
if (batch.abiVersion !== runtimeAbiVersion) {
|
||||
let batch;
|
||||
try {
|
||||
batch = decodeBatch(buffer);
|
||||
} catch (error) {
|
||||
if (error?.code === "HEMX_UNSUPPORTED_ABI") {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const expected = root && root.getAttribute(FINGERPRINT);
|
||||
if (expected && String(batch.fingerprint) !== expected) {
|
||||
location.reload();
|
||||
@@ -901,14 +906,30 @@
|
||||
}
|
||||
|
||||
function base64UrlBytes(encoded) {
|
||||
const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/");
|
||||
if (typeof encoded !== "string" || encoded.length > Math.ceil(MAX_WIRE_BYTES * 4 / 3) + 4) {
|
||||
throw new Error(`encoded hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
|
||||
}
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
|
||||
}
|
||||
|
||||
const MAX_WIRE_BYTES = 1024 * 1024;
|
||||
const MAX_WIRE_ITEMS = 1024;
|
||||
const MAX_WIRE_FIELD_BYTES = 256 * 1024;
|
||||
|
||||
function boundedLength(value, maximum, field) {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
throw new Error(`${field} length ${value} exceeds ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function postcardDecoder(bytes) {
|
||||
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
|
||||
let offset = 0;
|
||||
const need = (len) => {
|
||||
boundedLength(len, bytes.length - offset, "hemx state field");
|
||||
const end = offset + len;
|
||||
if (end > bytes.length) throw new Error("truncated hemx state");
|
||||
const slice = bytes.subarray(offset, end);
|
||||
@@ -916,24 +937,31 @@
|
||||
return slice;
|
||||
};
|
||||
const varint = () => {
|
||||
let shift = 0;
|
||||
let value = 0;
|
||||
for (;;) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const byte = need(1)[0];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (index === 4 && byte > 0x0f) throw new Error("oversized hemx state varint");
|
||||
value += (byte & 0x7f) * (2 ** (index * 7));
|
||||
if ((byte & 0x80) === 0) return value >>> 0;
|
||||
shift += 7;
|
||||
}
|
||||
throw new Error("oversized hemx state varint");
|
||||
};
|
||||
const bytesField = () => need(boundedLength(varint(), MAX_WIRE_FIELD_BYTES, "hemx state bytes"));
|
||||
const vec = (read) => {
|
||||
const length = boundedLength(varint(), MAX_WIRE_ITEMS, "hemx state vector");
|
||||
const values = [];
|
||||
for (let index = 0; index < length; index += 1) values.push(read());
|
||||
return values;
|
||||
};
|
||||
const bytesField = () => need(varint());
|
||||
const vec = (read) => Array.from({ length: varint() }, read);
|
||||
return { varint, bytes: bytesField, vec, done: () => offset === bytes.length };
|
||||
}
|
||||
|
||||
function decoder(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx batch exceeds ${MAX_WIRE_BYTES} bytes`);
|
||||
let offset = 0;
|
||||
const need = (len) => {
|
||||
boundedLength(len, bytes.length - offset, "hemx batch field");
|
||||
const end = offset + len;
|
||||
if (end > bytes.length) throw new Error("truncated hemx batch");
|
||||
const slice = bytes.subarray(offset, end);
|
||||
@@ -950,21 +978,36 @@
|
||||
const hi = BigInt(u32());
|
||||
return lo | (hi << 32n);
|
||||
};
|
||||
const str = () => new TextDecoder().decode(need(u32()));
|
||||
const option = (read) => u8() === 0 ? null : read();
|
||||
const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() });
|
||||
const str = () => new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")),
|
||||
);
|
||||
const enumValue = (values, field) => {
|
||||
const discriminant = u8();
|
||||
if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`);
|
||||
return values[discriminant];
|
||||
};
|
||||
const option = (read) => {
|
||||
const discriminant = u8();
|
||||
if (discriminant === 0) return null;
|
||||
if (discriminant === 1) return read();
|
||||
throw new Error(`unknown hemx option ${discriminant}`);
|
||||
};
|
||||
const resource = () => ({ kind: enumValue(["slot", "atom", "handle", "form"], "hemx resource"), id: u32() });
|
||||
const scope = () => {
|
||||
const kind = u8();
|
||||
if (kind === 0) return null;
|
||||
return { kind: kind === 1 ? "key" : "field", value: str() };
|
||||
if (kind === 1) return { kind: "key", value: str() };
|
||||
if (kind === 2) return { kind: "field", value: str() };
|
||||
throw new Error(`unknown hemx scope ${kind}`);
|
||||
};
|
||||
const ref = () => ({ resource: resource(), scope: scope() });
|
||||
const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() });
|
||||
const payload = () => ({ kind: enumValue(["text", "html"], "hemx payload"), value: str() });
|
||||
const scroll = () => {
|
||||
const kind = u8();
|
||||
if (kind === 0) return "preserve";
|
||||
if (kind === 1) return "top";
|
||||
return { kind: "element", target: ref() };
|
||||
if (kind === 2) return { kind: "element", target: ref() };
|
||||
throw new Error(`unknown hemx scroll behavior ${kind}`);
|
||||
};
|
||||
const effect = () => {
|
||||
const kind = u8();
|
||||
@@ -974,18 +1017,29 @@
|
||||
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) };
|
||||
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) };
|
||||
if (kind === 5) return { kind: "focus", target: ref() };
|
||||
if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) };
|
||||
if (kind === 6) return { kind: "navigate", url: str(), mode: enumValue(["push", "replace", "redirect"], "hemx navigation mode"), scroll: scroll(), title: option(str) };
|
||||
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
|
||||
throw new Error(`unknown hemx effect ${kind}`);
|
||||
};
|
||||
const vec = (read) => Array.from({ length: u32() }, read);
|
||||
const vec = (read) => {
|
||||
const length = boundedLength(u32(), MAX_WIRE_ITEMS, "hemx effect vector");
|
||||
const values = [];
|
||||
for (let index = 0; index < length; index += 1) values.push(read());
|
||||
return values;
|
||||
};
|
||||
return { u8, u32, u64, vec, effect, done: () => offset === bytes.length };
|
||||
}
|
||||
|
||||
function decodeBatch(buffer) {
|
||||
const d = decoder(buffer);
|
||||
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "HEMX") throw new Error("bad hemx batch magic");
|
||||
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
|
||||
const abiVersion = d.u32();
|
||||
if (abiVersion !== runtimeAbiVersion) {
|
||||
const error = new Error(`unsupported hemx batch ABI version ${abiVersion}; expected ${runtimeAbiVersion}`);
|
||||
error.code = "HEMX_UNSUPPORTED_ABI";
|
||||
throw error;
|
||||
}
|
||||
const batch = { abiVersion, fingerprint: d.u64(), ops: d.vec(d.effect) };
|
||||
if (!d.done()) throw new Error("trailing hemx batch bytes");
|
||||
return batch;
|
||||
}
|
||||
|
||||
+29
-2
@@ -12,6 +12,10 @@ pub use wasm_bindgen::*;
|
||||
|
||||
pub const CLIENT_EVENT_ABI_VERSION: u32 = 1;
|
||||
pub const CLIENT_STATE_ABI_VERSION: u32 = 1;
|
||||
const MAX_CLIENT_EVENT_KIND_BYTES: usize = 256;
|
||||
const MAX_CLIENT_EVENT_VALUE_BYTES: usize = 64 * 1024;
|
||||
const MAX_CLIENT_EVENT_KEY_BYTES: usize = 1024;
|
||||
const MAX_CLIENT_STATE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Versioned browser event accepted by client-local handlers.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -48,14 +52,37 @@ pub fn decode_client_inputs(
|
||||
"unsupported client-local event ABI version {event_version}; expected {CLIENT_EVENT_ABI_VERSION}"
|
||||
));
|
||||
}
|
||||
if kind.is_empty() {
|
||||
return Err("invalid client-local event payload: event kind is empty".to_owned());
|
||||
if kind.is_empty() || kind.len() > MAX_CLIENT_EVENT_KIND_BYTES {
|
||||
return Err(format!(
|
||||
"invalid client-local event payload: event kind must contain 1..={MAX_CLIENT_EVENT_KIND_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
if value
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > MAX_CLIENT_EVENT_VALUE_BYTES)
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid client-local event payload: value exceeds {MAX_CLIENT_EVENT_VALUE_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
if key
|
||||
.as_ref()
|
||||
.is_some_and(|key| key.len() > MAX_CLIENT_EVENT_KEY_BYTES)
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid client-local event payload: key exceeds {MAX_CLIENT_EVENT_KEY_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
if state_version != CLIENT_STATE_ABI_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported client-local state ABI version {state_version}; expected {CLIENT_STATE_ABI_VERSION}"
|
||||
));
|
||||
}
|
||||
if encoded_state.len() > MAX_CLIENT_STATE_BYTES {
|
||||
return Err(format!(
|
||||
"invalid client-local state payload: state exceeds {MAX_CLIENT_STATE_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
ClientEvent {
|
||||
kind,
|
||||
|
||||
@@ -226,6 +226,81 @@ async fn flat_patch_persists_offline_then_uploads_with_same_operation_identity(
|
||||
"const root = document.querySelector('[data-hemx-root]'); return root.hasAttribute('data-hemx-client-ready') && root.hasAttribute('data-hemx-sync-ready')",
|
||||
)
|
||||
.await?;
|
||||
let rejected_inputs = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
(async () => {
|
||||
const { reorder_card: handler } = await import('/kanban_client.js');
|
||||
const errors = {};
|
||||
for (const [name, args] of [
|
||||
['unknown-version', [99, 'click', null, null, null, 1, '1|2']],
|
||||
['oversized-kind', [1, 'x'.repeat(257), null, null, null, 1, '1|2']],
|
||||
['oversized-value', [1, 'click', 'x'.repeat(64 * 1024 + 1), null, null, 1, '1|2']],
|
||||
['oversized-key', [1, 'keydown', null, null, 'x'.repeat(1025), 1, '1|2']],
|
||||
['unknown-state-version', [1, 'click', null, null, null, 99, '1|2']],
|
||||
['oversized-state', [1, 'click', null, null, null, 1, 'x'.repeat(1024 * 1024 + 1)]],
|
||||
]) {
|
||||
try {
|
||||
await handler(...args);
|
||||
errors[name] = null;
|
||||
} catch (error) {
|
||||
errors[name] = String(error);
|
||||
}
|
||||
}
|
||||
done(errors);
|
||||
})().catch((error) => done({ harness: String(error) }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert!(
|
||||
rejected_inputs
|
||||
.as_object()
|
||||
.expect("client rejection record")
|
||||
.values()
|
||||
.all(|error| error.as_str().is_some_and(|message| !message.is_empty())),
|
||||
"malformed client-local input reached the handler: {rejected_inputs}"
|
||||
); // req: security/005
|
||||
let rejected_sync = driver
|
||||
.execute_async(
|
||||
r#"
|
||||
const done = arguments[arguments.length - 1];
|
||||
(async () => {
|
||||
const root = document.querySelector('[data-hemx-root]');
|
||||
const before = document.querySelector('#kanban-status').textContent;
|
||||
const errors = {};
|
||||
for (const [name, detail] of [
|
||||
['malformed', '{'],
|
||||
['unknown-version', JSON.stringify({ schemaVersion: 99, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'done' })],
|
||||
['invalid-key', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'value', value: 'done' })],
|
||||
['oversized-value', JSON.stringify({ schemaVersion: 1, idempotencyKey: 'event', operationId: 'event', key: 'cardColumn', value: 'x'.repeat(4097) })],
|
||||
]) {
|
||||
root.removeAttribute('data-hemx-sync-error');
|
||||
document.dispatchEvent(new CustomEvent('hemx:sync-patch', { detail }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
errors[name] = root.getAttribute('data-hemx-sync-error');
|
||||
}
|
||||
done({ errors, pending: root.getAttribute('data-hemx-sync-pending'), unchanged: before === document.querySelector('#kanban-status').textContent });
|
||||
})().catch((error) => done({ harness: String(error) }));
|
||||
"#,
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.clone();
|
||||
assert!(
|
||||
rejected_sync["errors"]
|
||||
.as_object()
|
||||
.expect("sync rejection record")
|
||||
.values()
|
||||
.all(|error| error.as_str().is_some_and(|message| !message.is_empty())),
|
||||
"malformed sync patch reached durable storage: {rejected_sync}"
|
||||
);
|
||||
assert_eq!(rejected_sync["pending"], "0", "{rejected_sync}");
|
||||
assert_eq!(rejected_sync["unchanged"], true, "{rejected_sync}");
|
||||
driver
|
||||
.execute(
|
||||
"window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', event => window.__clientErrors.push(event.detail)); Object.defineProperty(Navigator.prototype, 'onLine', { configurable: true, get: () => false }); document.querySelector('[data-hemx-client-event=drop]').dispatchEvent(new Event('drop', { bubbles: true })); return true",
|
||||
|
||||
Reference in New Issue
Block a user