Self-hosted endpoints on a LAN are sometimes protected by an API key. The admin "Local" add/test form only sent base_url (+ model_type), so such an endpoint could not be added — it just errored out — even though the backend POST /api/model-endpoints and /model-endpoints/test already accept an optional api_key form field (the cloud "API" form already uses it). Adds an optional masked "API key" input (adm-epLocalApiKey) to the Local form and wires it into the local Test and Add handlers, sending api_key only when filled (an empty value is omitted so we never send a blank Bearer). The field is cleared after a successful add, matching the cloud form. Tested: tests/test_local_endpoint_api_key_js.py extracts the two click handlers and runs them under node with mocked DOM/FormData/fetch, asserting api_key is sent when the field is filled and omitted when blank, plus that the input exists as a password field. `node --check static/js/admin.js` passes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
"""Behavioral test for issue #353 — Local LLM endpoints behind an API key.
|
|
|
|
The admin "Local" add/test form previously sent only `base_url` (+ model_type),
|
|
so a self-hosted endpoint protected by an API key could never be added — it just
|
|
errored out. The backend `POST /api/model-endpoints` and `/model-endpoints/test`
|
|
already accept an `api_key` form field; the fix wires the new `adm-epLocalApiKey`
|
|
input into the local Test and Add handlers.
|
|
|
|
admin.js can't be imported standalone (browser-only deps), so — same approach as
|
|
tests/test_local_endpoint_js.py — we extract the two click-handler bodies from
|
|
source and run them under node with mocked DOM/FormData/fetch, asserting the
|
|
outgoing form data contains `api_key` exactly when the key field is filled.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO = Path(__file__).resolve().parent.parent
|
|
_ADMIN_JS = _REPO / "static" / "js" / "admin.js"
|
|
_INDEX_HTML = _REPO / "static" / "index.html"
|
|
_HAS_NODE = shutil.which("node") is not None
|
|
|
|
|
|
def _extract_handler_body(src: str, marker: str) -> str:
|
|
"""Return the body (without the outer braces) of the arrow function that
|
|
immediately follows `marker` in `src`, using a quote-aware brace matcher."""
|
|
start = src.index(marker) + len(marker)
|
|
brace = src.index("{", start)
|
|
i = brace + 1
|
|
depth = 1
|
|
quote = None
|
|
escaped = False
|
|
while i < len(src):
|
|
c = src[i]
|
|
if quote:
|
|
if escaped:
|
|
escaped = False
|
|
elif c == "\\":
|
|
escaped = True
|
|
elif c == quote:
|
|
quote = None
|
|
elif c in "'\"`":
|
|
quote = c
|
|
elif c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[brace + 1:i]
|
|
i += 1
|
|
raise AssertionError(f"unbalanced braces after marker: {marker!r}")
|
|
|
|
|
|
_HARNESS = """
|
|
let appended = [];
|
|
class FormData {{ append(k, v) {{ appended.push([k, String(v)]); }} }}
|
|
const FIELDS = {fields};
|
|
function el(id) {{
|
|
if (!(id in FIELDS)) return null;
|
|
return {{
|
|
get value() {{ return FIELDS[id]; }},
|
|
set value(x) {{ FIELDS[id] = x; }},
|
|
disabled: false, textContent: '',
|
|
classList: {{ add() {{}}, remove() {{}} }},
|
|
}};
|
|
}}
|
|
function _endpointMsg() {{ return {{ textContent: '', className: '' }}; }}
|
|
function _normalizeBaseUrl(u) {{ return u; }}
|
|
function _renderEndpointTestResult() {{}}
|
|
async function loadEndpoints() {{}}
|
|
async function _selectAddedModelInChat() {{}}
|
|
let _recentlyAddedEpId = null;
|
|
const localTestBtn = {{ disabled: false, textContent: '' }};
|
|
const localAddBtn = {{ disabled: false, textContent: '' }};
|
|
async function fetch() {{
|
|
return {{ ok: true, async json() {{ return {{ id: 'x', models: [], online: true, status: 'ok' }}; }} }};
|
|
}}
|
|
async function run() {{ {body} }}
|
|
run().then(() => console.log(JSON.stringify(appended)))
|
|
.catch((e) => {{ console.error(e); process.exit(2); }});
|
|
"""
|
|
|
|
|
|
def _run_handler(body: str, fields: dict) -> list:
|
|
js = _HARNESS.format(fields=json.dumps(fields), body=body)
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
|
)
|
|
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{js}"
|
|
return json.loads(proc.stdout.strip())
|
|
|
|
|
|
def _handler(marker: str) -> str:
|
|
return _extract_handler_body(_ADMIN_JS.read_text(encoding="utf-8"), marker)
|
|
|
|
|
|
_TEST_MARKER = "localTestBtn.addEventListener('click', async () => "
|
|
_ADD_MARKER = "localAddBtn.addEventListener('click', async () => "
|
|
|
|
|
|
def test_local_form_has_api_key_input():
|
|
html = _INDEX_HTML.read_text(encoding="utf-8")
|
|
pos = html.find('id="adm-epLocalApiKey"')
|
|
assert pos != -1, "adm-epLocalApiKey input missing from index.html"
|
|
# Isolate the enclosing <input ...> tag and require it to be a masked field,
|
|
# like the cloud form's API-key input.
|
|
tag = html[html.rfind("<input", 0, pos):html.index(">", pos) + 1]
|
|
assert 'type="password"' in tag, f"local API key must be a password input: {tag}"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
@pytest.mark.parametrize("marker", [_TEST_MARKER, _ADD_MARKER])
|
|
def test_api_key_sent_when_filled(marker):
|
|
fields = {"adm-epLocalUrl": "http://localhost:8002/v1",
|
|
"adm-epLocalApiKey": "sk-secret", "adm-epLocalType": "llm"}
|
|
appended = dict(_run_handler(_handler(marker), fields))
|
|
assert appended.get("base_url") == "http://localhost:8002/v1"
|
|
assert appended.get("api_key") == "sk-secret", f"api_key not sent: {appended}"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
@pytest.mark.parametrize("marker", [_TEST_MARKER, _ADD_MARKER])
|
|
def test_api_key_omitted_when_blank(marker):
|
|
fields = {"adm-epLocalUrl": "http://localhost:8002/v1",
|
|
"adm-epLocalApiKey": "", "adm-epLocalType": "llm"}
|
|
keys = [k for k, _ in _run_handler(_handler(marker), fields)]
|
|
assert "base_url" in keys
|
|
assert "api_key" not in keys, "blank key must not be appended (avoids empty Bearer)"
|