a(c,t)))break e;e[r]=c,e[s]=t,r=s}}}return n}function a(e,n){var t=e.sortIndex-n.sortIndex;return 0!==t?t:e.id-n.id}if("object"===typeof performance&&"function"===typeof performance.now){var o=performance;n.unstable_now=function(){return o.now()}}else{var u=Date,i=u.now();n.unstable_now=function(){return u.now()-i}}var s=[],c=[],f=1,d=null,p=3,m=!1,h=!1,v=!1,g="function"===typeof setTimeout?setTimeout:null,y="function"===typeof clearTimeout?clearTimeout:null,b="undefined"!==typeof setImmediate?setImmediate:null;function k(e){for(var n=r(c);null!==n;){if(null===n.callback)l(c);else{if(!(n.startTime<=e))break;l(c),n.sortIndex=n.expirationTime,t(s,n)}n=r(c)}}function w(e){if(v=!1,k(e),!h)if(null!==r(s))h=!0,R(S);else{var n=r(c);null!==n&&M(w,n.startTime-e)}}function S(e,t){h=!1,v&&(v=!1,y(C),C=-1),m=!0;var a=p;try{for(k(t),d=r(s);null!==d&&(!(d.expirationTime>t)||e&&!z());){var o=d.callback;if("function"===typeof o){d.callback=null,p=d.priorityLevel;var u=o(d.expirationTime<=t);t=n.unstable_now(),"function"===typeof u?d.callback=u:d===r(s)&&l(s),k(t)}else l(s);d=r(s)}if(null!==d)var i=!0;else{var f=r(c);null!==f&&M(w,f.startTime-t),i=!1}return i}finally{d=null,p=a,m=!1}}"undefined"!==typeof navigator&&void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var x,E=!1,_=null,C=-1,P=5,N=-1;function z(){return!(n.unstable_now()-Ne||125o?(e.sortIndex=a,t(c,e),null===r(s)&&e===r(c)&&(v?(y(C),C=-1):v=!0,M(w,a-o))):(e.sortIndex=u,t(s,e),h||m||(h=!0,R(S))),e},n.unstable_shouldYield=z,n.unstable_wrapCallback=function(e){var n=p;return function(){var t=p;p=n;try{return e.apply(this,arguments)}finally{p=t}}}},340(e,n,t){e.exports=t(761)}};const n={};function t(r){const l=n[r];if(void 0!==l)return l.exports;const a=n[r]={exports:{}};return e[r].call(a.exports,a,a.exports,t),a.exports}t.m=e,(()=>{const e=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;let n;t.t=function(r,l){if(1&l&&(r=this(r)),8&l)return r;if("object"===typeof r&&r){if(4&l&&r.__esModule)return r;if(16&l&&"function"===typeof r.then)return r}const a=Object.create(null);t.r(a);const o={};n=n||[null,e({}),e([]),e(e)];for(var u=2&l&&r;("object"==typeof u||"function"==typeof u)&&!~n.indexOf(u);u=e(u))Object.getOwnPropertyNames(u).forEach(e=>o[e]=()=>r[e]);return o.default=()=>r,t.d(a,o),a}})(),t.d=(e,n)=>{if(Array.isArray(n))for(var r=0;rPromise.all(Object.keys(t.f).reduce((n,r)=>(t.f[r](e,n),n),[])),t.u=e=>"static/js/"+e+"."+{221:"0fc95064",810:"16fb44d2"}[e]+".chunk.js",t.miniCssF=e=>{},t.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{const e={},n="bot-virus-browser-verification-page:";t.l=(r,l,a,o)=>{if(e[r])return void e[r].push(l);let u,i;if(void 0!==a){const e=document.getElementsByTagName("script");for(var s=0;s{u.onerror=u.onload=null,clearTimeout(f);const l=e[r];if(delete e[r],u.parentNode&&u.parentNode.removeChild(u),l&&l.forEach(e=>e(t)),n)return n(t)},f=setTimeout(c.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=c.bind(null,u.onerror),u.onload=c.bind(null,u.onload),i&&document.head.appendChild(u)}})(),t.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.p="/",(()=>{const e={792:0};t.f.j=(n,r)=>{let l=t.o(e,n)?e[n]:void 0;if(0!==l)if(l)r.push(l[2]);else{const a=new Promise((t,r)=>l=e[n]=[t,r]);r.push(l[2]=a);const o=t.p+t.u(n),u=new Error,i=r=>{if(t.o(e,n)&&(l=e[n],0!==l&&(e[n]=void 0),l)){const e=r&&("load"===r.type?"missing":r.type),t=r&&r.target&&r.target.src;u.message="Loading chunk "+n+" failed.\n("+e+": "+t+")",u.name="ChunkLoadError",u.type=e,u.request=t,l[1](u)}};t.l(o,i,"chunk-"+n,n)}};const n=(n,r)=>{let[l,a,o]=r;var u,i,s=0;if(l.some(n=>0!==e[n])){for(u in a)t.o(a,u)&&(t.m[u]=a[u]);if(o)o(t)}for(n&&n(r);s(window.BVCollector=e.default||e,t.e(810).then(t.t.bind(t,810,23)))).catch(e=>{const n=document.getElementById("status");n&&(n.textContent="Verification unavailable"),console.error("[BV] runtime failed to load:",e)})})();
\ No newline at end of file
diff --git a/src/bv_challenge/challenge/templates/html/static/js/main.35a65fc2.js.LICENSE.txt b/src/bv_challenge/challenge/templates/html/static/js/main.35a65fc2.js.LICENSE.txt
new file mode 100644
index 0000000..a253b5a
--- /dev/null
+++ b/src/bv_challenge/challenge/templates/html/static/js/main.35a65fc2.js.LICENSE.txt
@@ -0,0 +1,39 @@
+/**
+ * @license React
+ * react-dom.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * react-jsx-runtime.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * react.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * scheduler.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
diff --git a/src/modules/__init__.py b/src/modules/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/modules/rest.mdm-sn-container-runner b/src/modules/rest.mdm-sn-container-runner
new file mode 160000
index 0000000..f724a1a
--- /dev/null
+++ b/src/modules/rest.mdm-sn-container-runner
@@ -0,0 +1 @@
+Subproject commit f724a1a94d4618f3bac712770bb328fa61d8e9f0
diff --git a/tests/test_eval_crypto.py b/tests/test_eval_crypto.py
new file mode 100644
index 0000000..78b55ba
--- /dev/null
+++ b/tests/test_eval_crypto.py
@@ -0,0 +1,130 @@
+"""Wire-format contract for the browser-side /_eval submission.
+
+The SDK encrypts the payload in the browser with a hybrid scheme and POSTs it to
+/_eval as ``{"error": {"data": "::::"}}``. The server
+decrypts it with the private ``vault_unlock`` wheel. This test pins that wire
+format: it builds a blob exactly the way the JS SDK does (RSA-OAEP-SHA256 for the
+AES key and IV, AES-256-CBC/PKCS7 for the body, base64 parts joined by ``::``)
+and asserts the real decryptor recovers the plaintext.
+
+If ``vault_unlock`` is not installed (local dev / CI without the private wheel),
+the round trip is verified against the public ``cryptography`` decryptor instead,
+so the format contract is still enforced.
+"""
+
+import os
+import json
+import base64
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives.padding import PKCS7
+
+import re
+
+# Must match api/core/constants/_regex.py ALPHANUM_CUSTOM_REGEX.
+_DATA_REGEX = re.compile(r"^[0-9a-zA-Z_\-:+/=]+$")
+
+
+def _gen_keypair():
+ priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ priv_pem = priv.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ).decode()
+ return priv, priv_pem
+
+
+def _sdk_style_encrypt(plaintext: str, public_key) -> str:
+ """Replicate the JS SDK's hybrid encryption -> '::::'."""
+ aes_key = os.urandom(32)
+ iv = os.urandom(16)
+
+ _padder = PKCS7(128).padder()
+ _padded = _padder.update(plaintext.encode()) + _padder.finalize()
+ _enc = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).encryptor()
+ _ct = _enc.update(_padded) + _enc.finalize()
+
+ def _rsa(data: bytes) -> bytes:
+ return public_key.encrypt(
+ data,
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None,
+ ),
+ )
+
+ _b64 = lambda b: base64.b64encode(b).decode()
+ return "::".join((_b64(_rsa(aes_key)), _b64(_rsa(iv)), _b64(_ct)))
+
+
+def _payload() -> dict:
+ return {
+ "schemaVersion": "bv-runtime-1",
+ "sessionId": "sess-1",
+ "movements": [],
+ "clicks": [],
+ "mouseDowns": [],
+ "mouseUps": [],
+ "keydowns": [],
+ "keyups": [],
+ "scroll": [],
+ "browserInfo": {"userAgent": "Chrome", "webdriver": False},
+ "automation": {"webdriver": False},
+ "sessionBinding": {"nonce": "nonce-abc123"},
+ }
+
+
+def test_blob_charset_is_eval_compatible() -> None:
+ priv, _ = _gen_keypair()
+ blob = _sdk_style_encrypt(json.dumps(_payload()), priv.public_key())
+ assert _DATA_REGEX.match(blob), "blob must match the /_eval data charset"
+ assert blob.count("::") == 2, "blob must be encKey::encIv::cipherText"
+
+
+def test_vault_unlock_decrypts_sdk_style_blob() -> None:
+ vault_unlock = pytest.importorskip(
+ "vault_unlock", reason="private vault_unlock wheel not installed"
+ )
+ priv, priv_pem = _gen_keypair()
+ original = _payload()
+ blob = _sdk_style_encrypt(json.dumps(original), priv.public_key())
+
+ plaintext = vault_unlock.decrypt_payload(
+ encrypted_text=blob, private_key_pem=priv_pem
+ )
+ assert json.loads(plaintext) == original
+
+
+def test_public_crypto_round_trip_matches() -> None:
+ # Format contract enforced even without the private wheel.
+ priv, _ = _gen_keypair()
+ original = _payload()
+ blob = _sdk_style_encrypt(json.dumps(original), priv.public_key())
+
+ enc_key_b64, enc_iv_b64, ct_b64 = blob.split("::")
+
+ def _rsa_dec(data: bytes) -> bytes:
+ return priv.decrypt(
+ data,
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None,
+ ),
+ )
+
+ aes_key = _rsa_dec(base64.b64decode(enc_key_b64))
+ iv = _rsa_dec(base64.b64decode(enc_iv_b64))
+ assert len(aes_key) == 32 and len(iv) == 16
+
+ _dec = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor()
+ _padded = _dec.update(base64.b64decode(ct_b64)) + _dec.finalize()
+ _unpadder = PKCS7(128).unpadder()
+ plaintext = (_unpadder.update(_padded) + _unpadder.finalize()).decode()
+
+ assert json.loads(plaintext) == original
diff --git a/tests/test_eval_runner.py b/tests/test_eval_runner.py
new file mode 100644
index 0000000..fea2fc8
--- /dev/null
+++ b/tests/test_eval_runner.py
@@ -0,0 +1,251 @@
+"""Service-level tests for /_eval attribution + /score orchestration.
+
+These exercise the framework-free core extracted into ``eval_runner`` together
+with the real ``RunStore``, so the concurrency/attribution behavior is covered
+without the global TaskManager, FastAPI, config, or the crypto/detector wheels.
+
+Covered: duplicate callbacks, out-of-order callbacks, unattributable payloads,
+stale/late callbacks from a previous run, runner failure, timeout padding, and
+score aggregation.
+"""
+
+import pytest
+
+from src.bv_challenge.challenge.api.endpoints.challenge import eval_runner
+from src.bv_challenge.challenge.api.endpoints.challenge.eval_runner import (
+ EvalOutcome,
+ process_eval,
+ run_scoring,
+)
+from src.bv_challenge.challenge.api.endpoints.challenge.session_store import RunStore
+
+
+# --------------------------------------------------------------------------- #
+# Test crypto stub: a payload "encrypted" for a session is "|".
+# Only the matching private key decrypts it; every other key raises -- mirroring
+# real per-session RSA trial-decryption without the wheel.
+# --------------------------------------------------------------------------- #
+def _encrypt(private_key: str, body: str = '{"ok": true}') -> str:
+ return f"{private_key}|{body}"
+
+
+def _decrypt(ciphertext: str, private_key: str) -> str:
+ tag, sep, body = ciphertext.partition("|")
+ if not sep or tag != private_key:
+ raise ValueError("wrong key")
+ return body
+
+
+def _const_score(value: float):
+ return lambda _data: value
+
+
+def _make_store(*sessions) -> RunStore:
+ # sessions: (session_id, private_key) pairs.
+ return RunStore.create("run", list(sessions))
+
+
+# ----------------------------- process_eval -------------------------------- #
+def test_attributes_and_records_by_trial_decryption() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"))
+
+ outcome = process_eval(
+ store, _encrypt("kb"), decrypt_fn=_decrypt, score_fn=_const_score(0.7)
+ )
+
+ assert outcome == EvalOutcome(status="recorded", session_id="b", score=0.7)
+ assert store.get_score("b") == 0.7
+ assert store.get_score("a") is None # untouched
+
+
+def test_out_of_order_callbacks_each_attributed_correctly() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"), ("c", "kc"))
+
+ # Arrive c, a, b -- order independent.
+ for sid, score in (("kc", 0.3), ("ka", 0.9), ("kb", 0.6)):
+ process_eval(
+ store, _encrypt(sid), decrypt_fn=_decrypt, score_fn=_const_score(score)
+ )
+
+ assert store.get_score("a") == 0.9
+ assert store.get_score("b") == 0.6
+ assert store.get_score("c") == 0.3
+ assert store.completed_count() == 3
+
+
+def test_duplicate_callback_is_ignored_and_not_recounted() -> None:
+ store = _make_store(("a", "ka"))
+ first = process_eval(
+ store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(0.8)
+ )
+ assert first.status == "recorded"
+
+ # Same session reports again with a different score -> ignored, original kept.
+ second = process_eval(
+ store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(0.1)
+ )
+ assert second == EvalOutcome(status="duplicate", session_id="a")
+ assert store.get_score("a") == 0.8
+ assert store.completed_count() == 1
+
+
+def test_duplicate_does_not_invoke_scorer() -> None:
+ store = _make_store(("a", "ka"))
+ process_eval(store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(0.5))
+
+ def _boom(_data):
+ raise AssertionError("scorer must not run for a duplicate callback")
+
+ outcome = process_eval(store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_boom)
+ assert outcome.status == "duplicate"
+
+
+def test_unattributable_payload_is_ignored_and_consumes_nothing() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"))
+
+ # Encrypted for a key not in this run (garbage/tampered/wrong key).
+ outcome = process_eval(
+ store, _encrypt("kZ"), decrypt_fn=_decrypt, score_fn=_const_score(0.9)
+ )
+
+ assert outcome == EvalOutcome(status="unattributable")
+ # No session was consumed or charged.
+ assert store.completed_count() == 0
+ assert store.get_score("a") is None
+ assert store.get_score("b") is None
+
+
+def test_malformed_ciphertext_is_unattributable() -> None:
+ store = _make_store(("a", "ka"))
+ outcome = process_eval(
+ store, "not-a-valid-ciphertext", decrypt_fn=_decrypt, score_fn=_const_score(1.0)
+ )
+ assert outcome.status == "unattributable"
+ assert store.completed_count() == 0
+
+
+def test_stale_callback_from_previous_run_is_ignored() -> None:
+ # Run 1 completes.
+ run1 = _make_store(("a", "k1a"), ("b", "k1b"))
+ process_eval(run1, _encrypt("k1a"), decrypt_fn=_decrypt, score_fn=_const_score(1.0))
+ run1.finalize(timeout_score=0.0)
+
+ # Run 2 starts with fresh keys; a late callback from run 1 arrives.
+ run2 = _make_store(("a", "k2a"), ("b", "k2b"))
+ outcome = process_eval(
+ run2, _encrypt("k1a"), decrypt_fn=_decrypt, score_fn=_const_score(1.0)
+ )
+
+ # run1's key does not exist in run2 -> unattributable -> ignored.
+ assert outcome.status == "unattributable"
+ assert run2.completed_count() == 0
+
+
+def test_record_race_collapses_to_duplicate() -> None:
+ # A store whose session looks open (is_completed False) but record() loses the
+ # race (returns False) -- the concurrent-callback branch must report duplicate.
+ class _RacyStore:
+ private_keys = {"a": "ka"}
+
+ def is_completed(self, _sid):
+ return False
+
+ def record(self, _sid, _score):
+ return False
+
+ def finalize(self, timeout_score):
+ return 0.0
+
+ outcome = process_eval(
+ _RacyStore(), _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(0.5)
+ )
+ assert outcome == EvalOutcome(status="duplicate", session_id="a")
+
+
+# ----------------------------- run_scoring --------------------------------- #
+def test_run_scoring_returns_runner_fail_score_and_skips_wait() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"))
+ waited = []
+
+ def _start():
+ raise RuntimeError("runner down")
+
+ def _wait():
+ waited.append(True)
+
+ score = run_scoring(
+ store=store,
+ start_runner=_start,
+ wait_for_completion=_wait,
+ timeout_score=0.0,
+ runner_fail_score=-1.0,
+ )
+
+ assert score == -1.0
+ assert waited == [] # never waited
+ assert store.completed_count() == 0 # store untouched
+
+
+def test_run_scoring_invokes_on_runner_error() -> None:
+ store = _make_store(("a", "ka"))
+ seen = {}
+
+ run_scoring(
+ store=store,
+ start_runner=lambda: (_ for _ in ()).throw(RuntimeError("boom")),
+ wait_for_completion=lambda: None,
+ timeout_score=0.0,
+ runner_fail_score=0.0,
+ on_runner_error=lambda err: seen.setdefault("err", str(err)),
+ )
+
+ assert seen["err"] == "boom"
+
+
+def test_run_scoring_aggregates_over_expected_with_timeout_padding() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"), ("c", "kc"))
+
+ def _start():
+ return None
+
+ def _wait():
+ # Two of three sessions report during the wait; "c" never does.
+ process_eval(
+ store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(1.0)
+ )
+ process_eval(
+ store, _encrypt("kb"), decrypt_fn=_decrypt, score_fn=_const_score(0.4)
+ )
+
+ score = run_scoring(
+ store=store,
+ start_runner=_start,
+ wait_for_completion=_wait,
+ timeout_score=0.0,
+ runner_fail_score=0.0,
+ )
+
+ # (1.0 + 0.4 + timeout 0.0) / expected 3
+ assert score == pytest.approx((1.0 + 0.4 + 0.0) / 3)
+ assert store.sessions["c"].timed_out is True
+
+
+def test_run_scoring_full_completion_averages_all() -> None:
+ store = _make_store(("a", "ka"), ("b", "kb"))
+
+ def _wait():
+ process_eval(
+ store, _encrypt("ka"), decrypt_fn=_decrypt, score_fn=_const_score(0.6)
+ )
+ process_eval(
+ store, _encrypt("kb"), decrypt_fn=_decrypt, score_fn=_const_score(0.8)
+ )
+
+ score = run_scoring(
+ store=store,
+ start_runner=lambda: None,
+ wait_for_completion=_wait,
+ timeout_score=0.0,
+ runner_fail_score=0.0,
+ )
+ assert score == pytest.approx((0.6 + 0.8) / 2)
diff --git a/tests/test_scoring.py b/tests/test_scoring.py
new file mode 100644
index 0000000..fad3f87
--- /dev/null
+++ b/tests/test_scoring.py
@@ -0,0 +1,53 @@
+import math
+
+from src.bv_challenge.challenge.api.endpoints.challenge import scoring
+from src.bv_challenge.challenge.api.endpoints.challenge.scoring import (
+ score_with_metrics_processor,
+)
+
+
+def test_score_with_metrics_processor_returns_valid_score() -> None:
+ score = score_with_metrics_processor(
+ {},
+ metrics_processor=lambda data: {"score": "0.72", "score_message": "internal"},
+ )
+
+ assert score == 0.72
+
+
+def test_score_with_metrics_processor_falls_back_on_bad_values() -> None:
+ bad_processors = [
+ lambda data: {},
+ lambda data: {"score": "not-a-number"},
+ lambda data: {"score": math.nan},
+ lambda data: {"score": math.inf},
+ ]
+
+ for processor in bad_processors:
+ assert score_with_metrics_processor({}, metrics_processor=processor) == 0.5
+
+
+def test_score_with_metrics_processor_falls_back_on_exception() -> None:
+ def raises(data: dict) -> dict:
+ raise RuntimeError("boom")
+
+ assert score_with_metrics_processor({}, metrics_processor=raises) == 0.5
+
+
+def test_score_with_metrics_processor_clamps_out_of_range_scores() -> None:
+ assert (
+ score_with_metrics_processor({}, metrics_processor=lambda data: {"score": 1.4})
+ == 1.0
+ )
+ assert (
+ score_with_metrics_processor({}, metrics_processor=lambda data: {"score": -0.2})
+ == 0.0
+ )
+
+
+def test_returns_error_score_when_no_processor_available(monkeypatch) -> None:
+ # Simulates the rt_bv_score wheel being absent: default processor is None,
+ # so the wrapper must fall back to the configured error score.
+ monkeypatch.setattr(scoring, "_default_metrics_processor", None)
+ assert scoring.score_with_metrics_processor({}, error_score=0.5) == 0.5
+
diff --git a/tests/test_scoring_shape.py b/tests/test_scoring_shape.py
new file mode 100644
index 0000000..50fae29
--- /dev/null
+++ b/tests/test_scoring_shape.py
@@ -0,0 +1,93 @@
+"""Tests for the public Layer 1 shape validator.
+
+The validator is intentionally structural only (no thresholds / weights / secret
+heuristics — those live in the private detector). It must accept well-formed
+payloads and reject malformed / tampered ones before they reach the wheel.
+"""
+
+from src.bv_challenge.challenge.api.endpoints.challenge import scoring
+
+
+def _valid_payload() -> dict:
+ return {
+ "movements": [{"x": 1, "y": 2, "t": 3}],
+ "clicks": [],
+ "mouseDowns": [],
+ "mouseUps": [],
+ "keydowns": [{"t": 1}],
+ "keyups": [{"t": 2}],
+ "scroll": [],
+ "browserInfo": {"userAgent": "x", "webdriver": False},
+ "pageTimings": {"pageLoadMs": 800},
+ "eventSequence": [{"type": "click", "t": 1, "pt": 0.5, "trusted": True}],
+ "targets": [],
+ "trustedEventStats": {"click": {"trusted": 1, "untrusted": 0}},
+ "taskProgress": {"usernameFocused": True},
+ }
+
+
+def test_accepts_well_formed_payload() -> None:
+ ok, reason = scoring.validate_shape(_valid_payload())
+ assert ok is True
+ assert reason is None
+
+
+def test_accepts_legacy_counts_only_payload() -> None:
+ # Older payloads without the advanced fields still pass (forward/backward compat).
+ legacy = {k: [] for k in ("movements", "clicks", "mouseDowns", "mouseUps", "keydowns", "keyups", "scroll")}
+ ok, _ = scoring.validate_shape(legacy)
+ assert ok is True
+
+
+def test_accepts_null_browser_info() -> None:
+ payload = _valid_payload()
+ payload["browserInfo"] = None # environment snapshot may be unavailable
+ ok, _ = scoring.validate_shape(payload)
+ assert ok is True
+
+
+def test_rejects_non_dict() -> None:
+ for _bad in ([], "x", 5, None):
+ ok, reason = scoring.validate_shape(_bad)
+ assert ok is False
+ assert reason
+
+
+def test_rejects_missing_required_field() -> None:
+ payload = _valid_payload()
+ del payload["movements"]
+ ok, reason = scoring.validate_shape(payload)
+ assert ok is False
+ assert "movements" in reason
+
+
+def test_rejects_required_field_wrong_type() -> None:
+ payload = _valid_payload()
+ payload["clicks"] = {"not": "a list"}
+ ok, reason = scoring.validate_shape(payload)
+ assert ok is False
+ assert "clicks" in reason
+
+
+def test_rejects_optional_list_field_wrong_type() -> None:
+ payload = _valid_payload()
+ payload["eventSequence"] = "tampered"
+ ok, reason = scoring.validate_shape(payload)
+ assert ok is False
+ assert "eventSequence" in reason
+
+
+def test_rejects_optional_dict_field_wrong_type() -> None:
+ payload = _valid_payload()
+ payload["taskProgress"] = ["nope"]
+ ok, reason = scoring.validate_shape(payload)
+ assert ok is False
+ assert "taskProgress" in reason
+
+
+def test_rejects_non_dict_browser_info() -> None:
+ payload = _valid_payload()
+ payload["browserInfo"] = "tampered"
+ ok, reason = scoring.validate_shape(payload)
+ assert ok is False
+ assert "browserInfo" in reason
diff --git a/tests/test_session_store.py b/tests/test_session_store.py
new file mode 100644
index 0000000..f2cdd10
--- /dev/null
+++ b/tests/test_session_store.py
@@ -0,0 +1,41 @@
+from src.bv_challenge.challenge.api.endpoints.challenge.session_store import RunStore
+
+
+def test_record_once_and_dedup() -> None:
+ store = RunStore.create("run1", [("a", "ka"), ("b", "kb")])
+ assert store.record("a", 0.8) is True
+ assert store.is_completed("a") is True
+ assert store.get_score("a") == 0.8
+
+ # Duplicate: not recorded again, original score preserved.
+ assert store.record("a", 0.1) is False
+ assert store.get_score("a") == 0.8
+ assert store.completed_count() == 1
+
+
+def test_record_unknown_session_is_rejected() -> None:
+ store = RunStore.create("run1", [("a", "ka")])
+ assert store.record("does-not-exist", 0.5) is False
+
+
+def test_finalize_pads_missing_with_timeout_score() -> None:
+ store = RunStore.create("run1", [("a", "ka"), ("b", "kb"), ("c", "kc")])
+ store.record("a", 1.0)
+ store.record("b", 0.5)
+
+ # c never reported -> counts as timeout (0.0), averaged over expected 3.
+ final = store.finalize(timeout_score=0.0)
+ assert final == (1.0 + 0.5 + 0.0) / 3
+ assert store.sessions["c"].timed_out is True
+
+
+def test_finalize_averages_over_expected_not_completed() -> None:
+ store = RunStore.create("run1", [("a", "ka"), ("b", "kb")])
+ store.record("a", 1.0)
+ # 1 of 2 completed -> average over the expected 2, not the 1 completed.
+ assert store.finalize(timeout_score=0.0) == 0.5
+
+
+def test_expected_sessions_tracks_creation() -> None:
+ store = RunStore.create("run1", [("a", "ka"), ("b", "kb")])
+ assert store.expected_sessions == 2
diff --git a/tests/test_web_page.py b/tests/test_web_page.py
new file mode 100644
index 0000000..8d5a563
--- /dev/null
+++ b/tests/test_web_page.py
@@ -0,0 +1,155 @@
+"""Tests for the /_web challenge page and the browser SDK (HBv6 non-behavioral).
+
+HBv6 scores non-behavioral browser/runtime/session integrity only. The page is
+deliberately minimal: it loads the SDK, which collects integrity signals and
+submits the encrypted payload to /_eval from the browser. There are no form
+fields, no verify button, no scroll content, and no behavioral collection.
+
+Two layers:
+ * Python: the /_web route serves the minimal verification page with the
+ config/session globals the SDK needs, and the SDK assets are served by the
+ static mount.
+ * Node: the SDK behaviour (auto-collect + encrypt + submit) is proven by the
+ standalone JS suites under tests/web/, shelled out here so a single
+ `pytest` run covers the whole browser-side flow. Skipped if node is absent.
+"""
+
+import shutil
+import pathlib
+import subprocess
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.bv_challenge.challenge.api.main import app
+
+client = TestClient(app)
+
+_WEB_DIR = pathlib.Path(__file__).resolve().parents[1] / "tests" / "web"
+
+
+def test_web_page_loads() -> None:
+ _response = client.get("/_web")
+ assert _response.status_code == 200
+ assert "text/html" in _response.headers["content-type"]
+
+
+def test_web_page_is_minimal_verification_page() -> None:
+ _html = client.get("/_web").text
+ # Minimal status page only.
+ assert "Browser verification" in _html
+ assert "Checking browser environment" in _html
+ assert 'id="status"' in _html
+
+
+def test_web_page_has_no_form_or_behavioral_elements() -> None:
+ _html = client.get("/_web").text
+ # No username/password fields, verify button, scroll content, or end button.
+ for _needle in (
+ 'name="username"',
+ 'name="password"',
+ 'id="login-button"',
+ 'id="content"',
+ "end-session",
+ "ACTIONS_LIST",
+ ):
+ assert _needle not in _html, _needle
+
+
+def test_web_page_exposes_minimal_task_config() -> None:
+ _html = client.get("/_web").text
+ assert "window.TASK_CONFIG" in _html
+ # Auto-submit is available and enabled.
+ assert '"/_eval"' in _html
+ assert "autoSubmit: true" in _html
+ assert '"data"' in _html
+ # Behavioral selector contract from the old page must be gone.
+ for _key in (
+ "usernameInput",
+ "passwordInput",
+ "verifyButton",
+ "scrollContainer",
+ "endSessionButton",
+ ):
+ assert _key not in _html, _key
+
+
+def test_web_page_exposes_session_binding_globals() -> None:
+ _html = client.get("/_web").text
+ assert "window.BV_SESSION" in _html
+ for _key in ("sessionId", "nonce", "publicKeyId", "configHash", "schemaVersion"):
+ assert _key in _html, _key
+ # Encryption key material the SDK needs for the browser-side submit.
+ assert "window.PUBLIC_KEY" in _html
+
+
+def test_web_page_wires_sdk() -> None:
+ _html = client.get("/_web").text
+ # SDK is wired in (collector before sdk).
+ assert "/static/js/collector.js" in _html
+ assert "/static/js/sdk.js" in _html
+
+
+def test_web_page_does_not_leak_scoring_internals() -> None:
+ _html = client.get("/_web").text.lower()
+ for _needle in (
+ "rt_bv_score",
+ "metricsprocessor",
+ "passes_gate",
+ "threshold",
+ "score_message",
+ "self_consistency",
+ "weighted_average",
+ "gate_fail",
+ "penalty",
+ ):
+ assert _needle not in _html
+
+
+def test_sdk_assets_do_not_leak_scoring_internals() -> None:
+ # The collected raw signals are public, but no scoring math/thresholds may
+ # appear in the shipped JS either.
+ for _path in ("/static/js/collector.js", "/static/js/sdk.js"):
+ _js = client.get(_path).text.lower()
+ for _needle in (
+ "rt_bv_score",
+ "metricsprocessor",
+ "threshold",
+ "weight",
+ "penalty",
+ "score_message",
+ "self_consistency",
+ ):
+ assert _needle not in _js, f"{_needle} leaked into {_path}"
+
+
+def test_sdk_does_not_collect_behavior() -> None:
+ # The active flow must not wire mouse/keyboard/scroll/click behavior.
+ _js = client.get("/static/js/sdk.js").text
+ for _needle in (
+ "mousemove",
+ "mousedown",
+ "mouseup",
+ "keydown",
+ "keyup",
+ "addEventListener",
+ ):
+ assert _needle not in _js, _needle
+
+
+def test_sdk_assets_are_served() -> None:
+ for _path in ("/static/js/collector.js", "/static/js/sdk.js"):
+ _response = client.get(_path)
+ assert _response.status_code == 200, _path
+ assert "javascript" in _response.headers["content-type"].lower()
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
+@pytest.mark.parametrize("script", ["collector.test.js", "sdk.test.js"])
+def test_sdk_node_suites(script: str) -> None:
+ _result = subprocess.run(
+ ["node", str(_WEB_DIR / script)],
+ capture_output=True,
+ text=True,
+ )
+ assert _result.returncode == 0, _result.stdout + _result.stderr
diff --git a/tests/web/collector.test.js b/tests/web/collector.test.js
new file mode 100644
index 0000000..429734a
--- /dev/null
+++ b/tests/web/collector.test.js
@@ -0,0 +1,128 @@
+"use strict";
+
+/**
+ * Unit tests for the pure browser/runtime-integrity collector. No DOM, no deps —
+ * runs under plain `node tests/web/collector.test.js`.
+ *
+ * HBv6 is non-behavioral: the collector accumulates raw integrity sections
+ * (browserInfo + automation / runtimeIntegrity / fingerprint / apiAvailability /
+ * navigator / display / correlation / sessionBinding) and keeps the legacy raw
+ * behavior series only as EMPTY lists for shape back-compat.
+ */
+
+const assert = require("assert");
+const path = require("path");
+
+const { createCollector, SECTION_KEYS } = require(path.join(
+ __dirname,
+ "..",
+ "..",
+ "src",
+ "bv_challenge",
+ "challenge",
+ "templates",
+ "html",
+ "static",
+ "js",
+ "collector.js"
+));
+
+let passed = 0;
+function test(name, fn) {
+ fn();
+ passed += 1;
+ console.log(" ok - " + name);
+}
+
+const LEGACY = ["movements", "clicks", "mouseDowns", "mouseUps", "keydowns", "keyups", "scroll"];
+const SECTIONS = [
+ "automation",
+ "runtimeIntegrity",
+ "fingerprint",
+ "apiAvailability",
+ "navigator",
+ "display",
+ "correlation",
+ "sessionBinding"
+];
+
+test("starts with legacy behavior series present and EMPTY (shape back-compat)", () => {
+ const d = createCollector({ sessionId: "s1" }).toJSON();
+ for (const k of LEGACY) {
+ assert.deepStrictEqual(d[k], [], k + " should start empty");
+ }
+});
+
+test("starts with all integrity sections null and a pageTimings container", () => {
+ const d = createCollector().toJSON();
+ assert.strictEqual(d.browserInfo, null);
+ for (const k of SECTIONS) {
+ assert.strictEqual(d[k], null, k + " should start null");
+ }
+ assert.deepStrictEqual(d.pageTimings, { pageLoadMs: null });
+});
+
+test("carries sessionId and schemaVersion through to the payload", () => {
+ const d = createCollector({ sessionId: "abc", schemaVersion: "bv-runtime-1" }).toJSON();
+ assert.strictEqual(d.sessionId, "abc");
+ assert.strictEqual(d.schemaVersion, "bv-runtime-1");
+});
+
+test("setBrowserInfo is first-write-wins", () => {
+ const c = createCollector();
+ c.setBrowserInfo({ userAgent: "ua-1" });
+ c.setBrowserInfo({ userAgent: "ua-2" });
+ assert.strictEqual(c.toJSON().browserInfo.userAgent, "ua-1");
+});
+
+test("setSection stores a known section once (first-write-wins)", () => {
+ const c = createCollector();
+ c.setSection("automation", { webdriver: false });
+ c.setSection("automation", { webdriver: true });
+ assert.deepStrictEqual(c.toJSON().automation, { webdriver: false });
+});
+
+test("setSection accepts every advertised section key", () => {
+ const c = createCollector();
+ for (const k of SECTIONS) {
+ c.setSection(k, { ok: true });
+ }
+ const d = c.toJSON();
+ for (const k of SECTIONS) {
+ assert.deepStrictEqual(d[k], { ok: true }, k);
+ }
+});
+
+test("setSection ignores unknown section names", () => {
+ const c = createCollector();
+ c.setSection("bogusSection", { x: 1 });
+ assert.ok(!("bogusSection" in c.toJSON()), "unknown sections must not be injected");
+});
+
+test("setSection ignores non-object values", () => {
+ const c = createCollector();
+ c.setSection("automation", "nope");
+ c.setSection("automation", 5);
+ assert.strictEqual(c.toJSON().automation, null);
+});
+
+test("setPageTiming sets pageLoadMs once", () => {
+ const c = createCollector();
+ c.setPageTiming("pageLoadMs", 800);
+ c.setPageTiming("pageLoadMs", 999);
+ assert.strictEqual(c.toJSON().pageTimings.pageLoadMs, 800);
+});
+
+test("finalize sets endedAt once and is idempotent", () => {
+ let n = 0;
+ const c = createCollector({ now: () => ++n });
+ const first = c.finalize().endedAt;
+ const second = c.finalize().endedAt;
+ assert.strictEqual(first, second);
+});
+
+test("SECTION_KEYS exposes exactly the known sections", () => {
+ assert.deepStrictEqual(Object.keys(SECTION_KEYS).sort(), SECTIONS.slice().sort());
+});
+
+console.log("\ncollector.test.js: " + passed + " passed");
diff --git a/tests/web/sdk.test.js b/tests/web/sdk.test.js
new file mode 100644
index 0000000..78c3099
--- /dev/null
+++ b/tests/web/sdk.test.js
@@ -0,0 +1,243 @@
+"use strict";
+
+/**
+ * Integration test for the HBv6 browser SDK against a minimal hand-rolled DOM +
+ * WebCrypto + fetch shim (no jsdom/playwright needed). Proves the requirements:
+ * - on load the SDK auto-collects NON-behavioral integrity signals
+ * (browserInfo + automation/runtimeIntegrity/apiAvailability/fingerprint/
+ * navigator/display/correlation/sessionBinding),
+ * - it submits the encrypted payload to TASK_CONFIG.evalUrl via fetch(),
+ * - window.BV_SUBMITTED flips true after a successful submit,
+ * - submission happens with NO behavioral input (no mouse/keyboard/scroll),
+ * - the encrypted blob is the hybrid wire format the server decryptor expects
+ * (key "::" iv "::" text) and round-trips back to the exact payload,
+ * - the payload carries no behavioral fields beyond the empty legacy arrays.
+ *
+ * node tests/web/sdk.test.js
+ */
+
+const assert = require("assert");
+const path = require("path");
+const { webcrypto } = require("crypto");
+
+const subtle = webcrypto.subtle;
+const JS_DIR = path.join(
+ __dirname, "..", "..", "src", "bv_challenge", "challenge",
+ "templates", "html", "static", "js"
+);
+
+// ALPHANUM_CUSTOM_REGEX from the server (api/core/constants/_regex.py).
+const DATA_REGEX = /^[0-9a-zA-Z_\-:+/=]+$/;
+
+function b64ToBytes(b64) {
+ return new Uint8Array(Buffer.from(b64, "base64"));
+}
+function bytesToPemSpki(buf) {
+ const b64 = Buffer.from(buf).toString("base64");
+ const lines = b64.match(/.{1,64}/g).join("\n");
+ return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
+}
+
+async function decryptBlob(blob, privateKey) {
+ const [encKeyB64, encIvB64, ctB64] = blob.split("::");
+ const rawKey = await subtle.decrypt({ name: "RSA-OAEP" }, privateKey, b64ToBytes(encKeyB64));
+ const iv = await subtle.decrypt({ name: "RSA-OAEP" }, privateKey, b64ToBytes(encIvB64));
+ const aesKey = await subtle.importKey("raw", rawKey, { name: "AES-CBC" }, false, ["decrypt"]);
+ const ptBuf = await subtle.decrypt(
+ { name: "AES-CBC", iv: new Uint8Array(iv) }, aesKey, b64ToBytes(ctB64)
+ );
+ return {
+ rawKey: new Uint8Array(rawKey),
+ iv: new Uint8Array(iv),
+ plaintext: Buffer.from(ptBuf).toString("utf-8")
+ };
+}
+
+async function main() {
+ // --- generate the session RSA keypair (RSA-OAEP / SHA-256), as the server does
+ const keyPair = await subtle.generateKey(
+ { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
+ true,
+ ["encrypt", "decrypt"]
+ );
+ const spki = await subtle.exportKey("spki", keyPair.publicKey);
+ const publicKeyPem = bytesToPemSpki(spki);
+
+ // --- minimal DOM / element shim -----------------------------------------
+ const statusEl = { textContent: "Checking…" };
+ const doc = {
+ getElementById(id) {
+ return id === "status" ? statusEl : null;
+ },
+ createElement() {
+ // Canvas-like: no real 2d/webgl context in the shim.
+ return { width: 0, height: 0, getContext() { return null; }, toDataURL() { return ""; } };
+ }
+ };
+
+ const store = {};
+ const localStorage = {
+ setItem(k, v) { store[k] = String(v); },
+ getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; }
+ };
+
+ const fetchCalls = [];
+ function fakeFetch(url, init) {
+ fetchCalls.push({ url, init });
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) });
+ }
+
+ const win = {
+ PUBLIC_KEY: publicKeyPem,
+ TASK_CONFIG: { evalUrl: "/_eval", payloadKey: "data", autoSubmit: true },
+ BV_SESSION: {
+ sessionId: "sess-1",
+ nonce: "nonce-abc123",
+ publicKeyId: "pk-deadbeef",
+ configHash: "cfg-1234",
+ schemaVersion: "bv-runtime-1"
+ },
+ crypto: webcrypto,
+ btoa: (s) => Buffer.from(s, "binary").toString("base64"),
+ atob: (s) => Buffer.from(s, "base64").toString("binary"),
+ fetch: fakeFetch,
+ localStorage,
+ location: { href: "https://challenge/_web", origin: "https://challenge" },
+ innerWidth: 1440,
+ innerHeight: 812,
+ outerWidth: 1440,
+ outerHeight: 900,
+ devicePixelRatio: 1
+ // AudioContext / RTCPeerConnection / WebAssembly / indexedDB intentionally absent.
+ };
+
+ global.window = win;
+ global.document = doc;
+ Object.defineProperty(global, "navigator", {
+ configurable: true,
+ value: {
+ userAgent:
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
+ platform: "Linux x86_64",
+ vendor: "Google Inc.",
+ language: "en-US",
+ languages: ["en-US", "en"],
+ webdriver: false,
+ hardwareConcurrency: 8,
+ maxTouchPoints: 0,
+ plugins: { length: 3 },
+ mimeTypes: { length: 2 },
+ userAgentData: { mobile: false, platform: "Linux", brands: [{ brand: "Chromium" }] }
+ }
+ });
+ global.screen = {
+ width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24
+ };
+ Object.defineProperty(global, "performance", {
+ configurable: true,
+ value: { getEntriesByType: (k) => (k === "navigation" ? [{ duration: 800 }] : []) }
+ });
+
+ // Wire the collector factory, then run the SDK IIFE against the shim.
+ win.BVCollector = require(path.join(JS_DIR, "collector.js"));
+ require(path.join(JS_DIR, "sdk.js"));
+
+ // The IIFE auto-runs on load; await an explicit run for a deterministic result.
+ const submitted = await win.BV_SDK.submitNow();
+
+ // Do all async crypto up front so the assertions below can stay synchronous.
+ const lastBody = JSON.parse(fetchCalls[fetchCalls.length - 1].init.body);
+ const blob = lastBody.error.data;
+ const decoded = await decryptBlob(blob, keyPair.privateKey);
+ const payload = JSON.parse(decoded.plaintext);
+
+ let passed = 0;
+ function test(name, fn) {
+ fn();
+ passed += 1;
+ console.log(" ok - " + name);
+ }
+
+ test("auto-submit resolves true and flips window.BV_SUBMITTED", () => {
+ assert.strictEqual(submitted, true);
+ assert.strictEqual(win.BV_SUBMITTED, true);
+ });
+
+ test("updates #status to 'Verification complete'", () => {
+ assert.strictEqual(statusEl.textContent, "Verification complete");
+ });
+
+ test("POSTs to TASK_CONFIG.evalUrl with the {error:{data}} envelope", () => {
+ const call = fetchCalls[fetchCalls.length - 1];
+ assert.strictEqual(call.url, "/_eval");
+ assert.strictEqual(call.init.method, "POST");
+ assert.ok(typeof blob === "string" && blob.length > 0, "blob must be a string");
+ assert.ok(DATA_REGEX.test(blob), "blob must match the server data charset");
+ });
+
+ test("blob is the hybrid key::iv::text wire format (3 parts)", () => {
+ assert.strictEqual(blob.split("::").length, 3, "expected encKey::encIv::cipherText");
+ });
+
+ test("blob round-trips: RSA-OAEP(key,iv) + AES-256-CBC(text) -> original payload", () => {
+ assert.strictEqual(decoded.rawKey.length, 32, "AES-256 key");
+ assert.strictEqual(decoded.iv.length, 16, "16-byte IV");
+ // Must equal what the SDK stashed locally too.
+ assert.strictEqual(decoded.plaintext, localStorage.getItem("data"));
+ });
+
+ test("collected payload carries the non-behavioral integrity sections", () => {
+ assert.ok(payload.browserInfo, "browserInfo present");
+ assert.strictEqual(payload.browserInfo.webdriver, false);
+ assert.ok(payload.browserInfo.userAgent.indexOf("Chrome") !== -1);
+ for (const k of [
+ "automation", "runtimeIntegrity", "apiAvailability", "fingerprint",
+ "navigator", "display", "correlation", "sessionBinding"
+ ]) {
+ assert.ok(payload[k] && typeof payload[k] === "object", k + " section present");
+ }
+ });
+
+ test("automation indicators reflect a clean (non-automated) shim", () => {
+ assert.strictEqual(payload.automation.webdriver, false);
+ assert.strictEqual(payload.automation.headlesschrome, false);
+ });
+
+ test("apiAvailability reports real capability booleans", () => {
+ assert.strictEqual(payload.apiAvailability.serviceWorker, false); // absent in shim navigator
+ assert.strictEqual(typeof payload.apiAvailability.indexedDb, "boolean");
+ });
+
+ test("sessionBinding echoes the injected BV_SESSION fields", () => {
+ assert.strictEqual(payload.sessionBinding.nonce, "nonce-abc123");
+ assert.strictEqual(payload.sessionBinding.sessionId, "sess-1");
+ assert.strictEqual(payload.sessionBinding.publicKeyId, "pk-deadbeef");
+ assert.strictEqual(payload.sessionBinding.schemaVersion, "bv-runtime-1");
+ });
+
+ test("legacy behavior series are present but EMPTY (no behavior collected)", () => {
+ for (const k of ["movements", "clicks", "mouseDowns", "mouseUps", "keydowns", "keyups", "scroll"]) {
+ assert.deepStrictEqual(payload[k], [], k + " must be empty");
+ }
+ });
+
+ test("NO behavioral fields leak into the payload", () => {
+ const banned = ["eventSequence", "targets", "taskProgress", "trustedEventStats", "buttonHoverToClickTime"];
+ const raw = localStorage.getItem("data");
+ for (const k of banned) {
+ assert.ok(raw.indexOf('"' + k + '"') === -1, "payload must not contain " + k);
+ }
+ });
+
+ test("submission required NO behavioral input (no events were dispatched)", () => {
+ // We never dispatched a mouse/key/scroll event; the SDK submitted anyway.
+ assert.strictEqual(win.BV_SUBMITTED, true);
+ });
+
+ console.log("\nsdk.test.js: " + passed + " passed");
+}
+
+main().catch((err) => {
+ console.error(err && err.stack ? err.stack : err);
+ process.exitCode = 1;
+});
diff --git a/src/bv_challenge/challenge/api/static/css/.gitkeep b/volumes/configs/simple-bot-backend/key/.gitkeep
similarity index 100%
rename from src/bv_challenge/challenge/api/static/css/.gitkeep
rename to volumes/configs/simple-bot-backend/key/.gitkeep
diff --git a/volumes/storage/rest-mdm-sn-container-runner/data/.gitkeep b/volumes/storage/rest-mdm-sn-container-runner/data/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/volumes/storage/rest-mdm-sn-container-runner/data/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/volumes/storage/rest-mdm-sn-container-runner/logs/.gitkeep b/volumes/storage/rest-mdm-sn-container-runner/logs/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/volumes/storage/rest-mdm-sn-container-runner/logs/.gitkeep
@@ -0,0 +1 @@
+