diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e5a24e..48135a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,40 @@ for 1.0.0 have something to be assembled from.
## Unreleased
+## 0.9.13
+
+**The testing pass.** 2140 tests became 2283, and writing them found four bugs
+that no amount of reading had.
+
+- Fixed: **the terminal silently stopped accepting input after a reconnect.**
+ Change the connection, or let the shell catch up after falling behind, and
+ every keystroke was dropped from then on — while output kept arriving, so the
+ panel looked perfectly healthy. It also announced "Disconnected. Close and
+ reopen to reconnect." about a shell that had just reconnected successfully.
+- Fixed: **on the Messages screen, half the keyboard did nothing.** Two scripts
+ were loaded twice there, so `Alt+B`, `Alt+E`, `Alt+T` and `Alt+I` toggled
+ their panel twice — which is to say not at all — while `/help` opened two
+ dialogs, `/image` posted the message twice, and picking an `@` mention
+ attached the file twice.
+- Fixed: **pressing the microphone while the permission prompt was up opened a
+ recording each time.** Only the last was stopped, so the browser's recording
+ indicator stayed on until the tab was closed.
+- Fixed: **a skill shared with you took its name out of your own library.**
+ Creating your own was refused with "a skill called that already exists. Edit
+ it instead" — naming a skill you cannot edit, because sharing grants reading
+ only. The model's `skill_create` hit the same dead end. Sharing a curated
+ skill with a team is what sharing is *for*.
+- Hints and timestamps are readable now. `--ink-faint` failed the accessibility
+ contrast minimum in **both** themes — 3.85:1 in Moria, 3.19:1 in Shire, where
+ 4.5:1 is the bar — so the smallest text on every screen was the hardest to
+ read.
+- The suite runs on **Python 3.11 and 3.12** as well as 3.14. It had only ever
+ run on 3.14, while the Docker image ships 3.12 and the packaging claimed 3.11
+ — so the one interpreter most people would actually run was the one nothing
+ had tested.
+- A `docs/notes/release-checklist.md` for the half of testing a machine cannot
+ do: a real endpoint, a real machine, real hardware, a real pair of eyes.
+
## 0.9.12
**The security pass.** Six findings, all fixed. None is reachable by simply
diff --git a/pyproject.toml b/pyproject.toml
index 939900a..ff53d20 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,5 +83,13 @@ ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
[tool.pytest.ini_options]
testpaths = ["tests"]
+# Registered so `-m "not slow"` works and an unknown-marker warning does not
+# become an error later. `slow` is for the tests that stand up something real:
+# a uvicorn subprocess on a port, an asyncssh server, a PTY, a git repository
+# built with subprocess. They are the ones worth having and the ones worth
+# being able to skip while iterating.
+markers = [
+ "slow: stands up a real server, shell or repository",
+]
asyncio_mode = "auto"
filterwarnings = ["ignore::DeprecationWarning"]
diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py
index 619aec4..d9d720c 100644
--- a/src/lembas/__init__.py
+++ b/src/lembas/__init__.py
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
-__version__ = "0.9.12"
+__version__ = "0.9.13"
diff --git a/src/lembas/services/library/skills.py b/src/lembas/services/library/skills.py
index 5ba888c..5390ccb 100644
--- a/src/lembas/services/library/skills.py
+++ b/src/lembas/services/library/skills.py
@@ -67,12 +67,39 @@ def get(db: DBSession, skill_id: str, user: User | None) -> Skill | None:
def by_name(db: DBSession, name: str, user: User | None) -> Skill | None:
- """Look one up the way the model refers to it."""
+ """Look one up the way the model refers to it.
+
+ Scoped to what this person can **see**, which is theirs plus anything
+ shared with them -- correct for `skill_get` and `skill_edit`, where a
+ skill somebody shared is exactly what the model is reaching for.
+
+ It is the wrong question for "is this name taken?"; see `owned_by_name`.
+ """
if user is None:
return None
return db.scalar(visible(db, user).where(Skill.name == slugify(name)))
+def owned_by_name(db: DBSession, name: str, owner: User) -> Skill | None:
+ """One of *this person's own* skills by name.
+
+ The uniqueness check used `by_name`, which is scoped to what is visible --
+ so a skill somebody shared with you took that name out of your library.
+ Sharing a curated skill with a team is the intended use of `library.share`,
+ and doing it silently reserved the name for everyone it reached: creating
+ your own was refused with "a skill called 'weekly-report' already exists.
+ Edit it instead", naming a row you cannot edit, because sharing grants
+ reading only. The model's `skill_create` got the same dead end.
+
+ The table's constraint is `(owner_id, name)`, so the question the check
+ should have been asking was always this one. `documents.create_base` next
+ door asks it correctly.
+ """
+ return db.scalar(
+ select(Skill).where(Skill.owner_id == owner.id, Skill.name == slugify(name))
+ )
+
+
def enabled_for(
db: DBSession, user: User | None, *, exclude: Iterable[str] = ()
) -> list[Skill]:
@@ -155,7 +182,7 @@ def create(
"A skill name must be two or more letters, numbers or hyphens, "
"such as 'weekly-report'."
)
- if by_name(db, slug, owner) is not None:
+ if owned_by_name(db, slug, owner) is not None:
raise SkillError(f"A skill called {slug!r} already exists. Edit it instead.")
if not description.strip():
raise SkillError(
diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css
index de707c5..d389b3c 100644
--- a/src/lembas/web/static/css/tokens.css
+++ b/src/lembas/web/static/css/tokens.css
@@ -155,7 +155,7 @@
--ink: #E4E8EC;
--ink-muted: #A2ADB8;
- --ink-faint: #6E7883;
+ --ink-faint: #7A848F;
--ink-inverse: #0B0E11;
/* Mithril: the cool primary, used for focus and interactive accents. */
@@ -263,7 +263,7 @@
--ink: #2C2419;
--ink-muted: #6A5C48;
- --ink-faint: #94856D;
+ --ink-faint: #756A55;
--ink-inverse: #FDFBF5;
/* Hobbit-door blue-green: the cool primary. */
diff --git a/src/lembas/web/static/js/audio.js b/src/lembas/web/static/js/audio.js
index d24adf5..99591e8 100644
--- a/src/lembas/web/static/js/audio.js
+++ b/src/lembas/web/static/js/audio.js
@@ -104,6 +104,17 @@
return;
}
+ /* Out of `idle` **before** awaiting permission, or the button stays
+ clickable for as long as the browser's prompt is up -- and the dispatcher
+ below starts a recording on every click in that window. Each one opened
+ its own stream and its own MediaRecorder, only the last of which was kept
+ in `recorder`, so stopping released one and left the rest live: the
+ browser's recording indicator stayed on until the tab was closed.
+
+ `working` already exists and is already styled, so this is the state the
+ button was missing rather than a new one. */
+ setMicState(button, "working");
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
chunks = [];
recorder = new MediaRecorder(stream);
@@ -124,6 +135,9 @@
recorder.start();
setMicState(button, "recording");
}).catch(function () {
+ // Back to idle, or a refused permission leaves a button nothing can press
+ // again -- including the reader who has just gone and allowed it.
+ setMicState(button, "idle");
notify("The microphone could not be opened. Permission may be blocked.", "error");
});
}
diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js
index a849e51..3df031a 100644
--- a/src/lembas/web/static/js/terminal.js
+++ b/src/lembas/web/static/js/terminal.js
@@ -125,10 +125,27 @@
"?cols=" + (term.cols || 80) + "&rows=" + (term.rows || 24);
say("Connecting…");
- socket = new WebSocket(url);
- socket.binaryType = "arraybuffer";
+ /* Held in a local as well as in `socket`, because every handler below has
+ to know *which* connection it belongs to.
- socket.onmessage = function (event) {
+ `close()` queues its event rather than firing it, and both reconnect
+ paths -- the `behind` frame and `repointTerminal` -- close the old
+ socket and immediately call this function. So the old socket's `close`
+ arrives *after* a new one has been assigned, and a handler that touched
+ the module-level `socket` was reaching past its own connection into the
+ live one. It set it to null: output kept arriving, because `onmessage`
+ is bound to the object, while `send`, `term.onData` and the paste
+ handler all gate on `socket && readyState === OPEN` -- so every
+ keystroke was dropped and no resize was ever sent again, on a panel that
+ looked perfectly healthy. And `closedOnPurpose` had already been cleared
+ for the new connection, so it said "Disconnected. Close and reopen to
+ reconnect." about a shell that had just reconnected. */
+ var opened = new WebSocket(url);
+ socket = opened;
+ opened.binaryType = "arraybuffer";
+
+ opened.onmessage = function (event) {
+ if (socket !== opened) return;
if (typeof event.data === "string") return control(event.data);
/* Written straight through as bytes. xterm's decoder is stateful across
calls, so a multi-byte character split across two frames still lands
@@ -136,12 +153,14 @@
term.write(new Uint8Array(event.data));
};
- socket.onclose = function () {
+ opened.onclose = function () {
+ if (socket !== opened) return;
socket = null;
if (!closedOnPurpose) say("Disconnected. Close and reopen to reconnect.");
};
- socket.onerror = function () {
+ opened.onerror = function () {
+ if (socket !== opened) return;
/* A failed handshake gives the page nothing: no status, no reason. So
this is a guess, and it names the likeliest cause rather than
pretending to know. */
diff --git a/src/lembas/web/templates/messages/index.html b/src/lembas/web/templates/messages/index.html
index b60a40a..d876713 100644
--- a/src/lembas/web/templates/messages/index.html
+++ b/src/lembas/web/templates/messages/index.html
@@ -105,8 +105,21 @@
{% endblock %}
+{#
+ `steps.js` only. `composer.js` and `commands.js` were listed here too, and
+ `base.html` already loads both on every page -- so this screen ran each of
+ them **twice**.
+
+ Each is an IIFE with its own state, and `stopPropagation()` does not stop a
+ second listener already bound to the same node. So: two composer menus stacked
+ on each other; one Enter on a highlighted `/` item running the command twice
+ (`/help` opening two dialogs, `/image` posting the message twice); an `@`
+ mention attaching its file twice; and `Alt+B`, `Alt+E`, `Alt+T` and `Alt+I`
+ toggling their panel twice, which is to say doing nothing at all.
+
+ None of it looks like a script loaded twice. Found by driving the file under a
+ DOM stub, which is the rule `CLAUDE.md` sets out and the reason it does.
+#}
{% block scripts %}
-
-
{% endblock %}
diff --git a/tests/conftest.py b/tests/conftest.py
index 5ff3e43..461f98a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -300,6 +300,112 @@ def control_named(html: str, name: str) -> dict[str, str]:
return found[0]
+def script(name: str) -> str:
+ """One of the shipped browser files, as text.
+
+ There is no JavaScript test runner here -- hard rule 1 keeps Node out of the
+ project -- so behaviour is driven by hand under a DOM stub and what the
+ suite pins is what the source says about itself.
+ """
+ import lembas
+
+ return (Path(lembas.__file__).parent / "web/static/js" / name).read_text(encoding="utf-8")
+
+
+def js_code(source: str) -> str:
+ """The same source with its comments blanked out.
+
+ These files are heavily commented, and every comment names the thing it is
+ explaining -- so `"list" in refresh` is true of a paragraph saying that
+ writing to `list` too early was the bug. An invariant about the code must
+ not be satisfiable by prose describing it. String literals are left intact,
+ because the assertions are usually about an event name inside one.
+ """
+ out: list[str] = []
+ at, end = 0, len(source)
+ while at < end:
+ char = source[at]
+ if char in "\"'`":
+ quote = char
+ out.append(char)
+ at += 1
+ while at < end:
+ if source[at] == "\\":
+ out.append(source[at : at + 2])
+ at += 2
+ continue
+ out.append(source[at])
+ at += 1
+ if source[at - 1] == quote:
+ break
+ continue
+ if char == "/" and source[at : at + 2] in ("//", "/*"):
+ stop = (
+ source.find("\n", at)
+ if source[at + 1] == "/"
+ else source.find("*/", at) + 2
+ )
+ if stop <= at:
+ stop = end
+ # Blanked rather than removed, so every offset still lines up.
+ out.append("".join(" " if c != "\n" else "\n" for c in source[at:stop]))
+ at = stop
+ continue
+ out.append(char)
+ at += 1
+ return "".join(out)
+
+
+def js_says(text: str, *pieces: str) -> bool:
+ """Whether the pieces appear, in order, whatever sits between them.
+
+ Asserting a line verbatim makes a test that fails on reindentation, which is
+ noise; asserting only that two words appear somewhere makes one that never
+ fails at all. This is the middle: the shape, not the spacing.
+ """
+ at = 0
+ for piece in pieces:
+ at = text.find(piece, at)
+ if at == -1:
+ return False
+ at += len(piece)
+ return True
+
+
+def js_function(source: str, name: str) -> str:
+ """The text of one named function, braces matched.
+
+ Lets a test ask about the inside of a handler rather than about the file --
+ "is `build()` called before anything writes to `list`" is a question about
+ `refresh`, and asking it of the whole source answers yes for the wrong
+ reason.
+ """
+ code = js_code(source)
+ # The parenthesis is load-bearing: `token` is a prefix of `tokenAt`, and
+ # asking about the wrong function is a test that passes for no reason.
+ start = code.index(f"function {name}(")
+ at = code.index("{", start)
+ depth, quote = 0, ""
+ while at < len(code):
+ char = code[at]
+ if quote:
+ if char == "\\":
+ at += 2
+ continue
+ if char == quote:
+ quote = ""
+ elif char in "\"'`":
+ quote = char
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return code[start : at + 1]
+ at += 1
+ raise AssertionError(f"no closing brace for {name}()")
+
+
@pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id.
diff --git a/tests/test_agent_browse.py b/tests/test_agent_browse.py
index e44a709..4e6561c 100644
--- a/tests/test_agent_browse.py
+++ b/tests/test_agent_browse.py
@@ -15,6 +15,9 @@ from sqlalchemy import select
from lembas.db.models import SshProfile, User
from lembas.services.agent import ssh as ssh_service
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_agent_command_edit.py b/tests/test_agent_command_edit.py
index 36674b1..a688fd4 100644
--- a/tests/test_agent_command_edit.py
+++ b/tests/test_agent_command_edit.py
@@ -20,6 +20,9 @@ from lembas.services import tools as tools_service
from lembas.services.agent import policy
from lembas.services.agent import ssh as ssh_service
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_agent_draft.py b/tests/test_agent_draft.py
index 21e5f8f..0fb6857 100644
--- a/tests/test_agent_draft.py
+++ b/tests/test_agent_draft.py
@@ -22,6 +22,8 @@ from lembas.services import settings_store
from lembas.services.agent import draft as draft_service
from lembas.services.agent import terminal as terminal_service
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
@pytest.fixture(autouse=True)
def _clean():
diff --git a/tests/test_agent_jobs.py b/tests/test_agent_jobs.py
index 6d10993..1297622 100644
--- a/tests/test_agent_jobs.py
+++ b/tests/test_agent_jobs.py
@@ -28,6 +28,9 @@ pytestmark = pytest.mark.skipif(
)
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
class LocalExecutor:
"""`SshExecutor.run`'s contract, run against the local shell.
diff --git a/tests/test_agent_profiles.py b/tests/test_agent_profiles.py
index 63a42bf..1298afd 100644
--- a/tests/test_agent_profiles.py
+++ b/tests/test_agent_profiles.py
@@ -16,6 +16,9 @@ from lembas.db.models import SshProfile, User
from lembas.services import settings_store
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_agent_ssh.py b/tests/test_agent_ssh.py
index badf7c0..a6c396c 100644
--- a/tests/test_agent_ssh.py
+++ b/tests/test_agent_ssh.py
@@ -14,6 +14,9 @@ import pytest
from lembas.services.agent import ssh
from lembas.services.agent.base import ExecError, ExecRequest
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py
index 9a8e245..e13e696 100644
--- a/tests/test_agent_tools.py
+++ b/tests/test_agent_tools.py
@@ -31,6 +31,9 @@ from lembas.services.agent import policy, session
from lembas.services.agent import ssh as ssh_service
from lembas.services.tools import RISK_EXECUTE
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_audio_js.py b/tests/test_audio_js.py
new file mode 100644
index 0000000..cfdab82
--- /dev/null
+++ b/tests/test_audio_js.py
@@ -0,0 +1,200 @@
+"""Dictation and read-aloud, checked without a runtime.
+
+There is no JavaScript test runner here and hard rule 1 keeps Node out of the
+project, so the behaviour is driven by hand under a DOM stub before committing.
+Both halves of this file are progressive -- without it the composer and the
+bubbles still work, they simply have two buttons that do nothing -- which is
+also the shape of every way it can break: a microphone left open, a button left
+disabled, a reply read out twice. None of them raises anything.
+
+What the suite can pin is that there is one recording state machine and one
+`` element, that every path out of a recording puts the button back, and
+that writing a transcript into the composer goes through the same two calls
+every other outside writer makes.
+"""
+
+from __future__ import annotations
+
+import re
+
+from .conftest import js_code, js_function, js_says, script
+
+SOURCE = script("audio.js")
+CODE = js_code(SOURCE)
+COMMANDS = js_code(script("commands.js"))
+
+
+# --- One state machine --------------------------------------------------------
+def test_there_is_one_delegated_listener_for_both_buttons():
+ """Delegated, because a message bubble is rendered from four places and
+ swapped by htmx constantly -- a listener bound per button would miss every
+ reply that arrived after load, which is all of them. One listener, and the
+ two buttons told apart by `closest`."""
+ assert CODE.count('document.addEventListener("click"') == 1
+ click = CODE[CODE.index('document.addEventListener("click"') :]
+ assert 'closest("[data-mic]")' in click
+ assert 'closest("[data-speak]")' in click
+
+
+def test_the_shortcuts_press_the_button_rather_than_repeating_the_machine():
+ """`Alt+M` and `Alt+R` dispatch by finding the existing control and calling
+ `.click()`, so this file keeps its one delegated listener and there is no
+ second copy of the recording state machine to fall out of step with it.
+
+ Asserted from commands.js, which is where the second copy would appear.
+ """
+ assert js_says(COMMANDS, "[data-mic]", "mic.click()")
+ assert js_says(COMMANDS, "[data-speak]", ".click()")
+
+ # Nothing here is exported, so there is nothing for a shortcut to reach for
+ # instead -- which is what keeps that true rather than merely current.
+ assert not re.search(r"window\.lembas\.\w+\s*=", CODE)
+ assert "MediaRecorder" not in COMMANDS
+
+
+def test_the_microphone_is_released_the_moment_recording_stops():
+ """Leaving the track live keeps the browser's recording indicator on long
+ after anyone is talking -- a self-hosted tool that appears to still be
+ listening is the one bug here nobody would forgive. Released in the `stop`
+ handler, before the upload, because the upload is a network round trip."""
+ start = js_function(SOURCE, "startRecording")
+ stop_handler = start[start.index('addEventListener("stop"') :]
+ assert "track.stop()" in stop_handler
+ assert stop_handler.index("track.stop()") < stop_handler.index("upload(")
+
+
+def test_a_recording_that_captured_nothing_does_not_upload():
+ """An empty blob is a press and an immediate second press. Uploading it
+ costs a round trip to be told nothing was heard, and leaves the button
+ disabled while that happens."""
+ start = js_function(SOURCE, "startRecording")
+ assert js_says(start, "blob.size", "upload(", "else", 'setMicState(button, "idle")')
+
+
+def test_every_way_out_of_an_upload_puts_the_button_back():
+ """`working` is the only state that disables the button, and a disabled
+ microphone that never comes back is a feature somebody has to reload the
+ page to use again. Both arms of the promise reset it -- and the error arm is
+ the one that gets forgotten, because it is the one nobody exercises."""
+ upload = js_function(SOURCE, "upload")
+ assert 'setMicState(button, "working")' in upload
+ assert upload.count('setMicState(button, "idle")') == 2, "a path out leaves it disabled"
+ assert ".catch(" in upload
+
+ state = js_function(SOURCE, "setMicState")
+ assert js_says(state, "button.disabled", '"working"')
+
+
+def test_the_label_says_what_the_next_press_will_do():
+ """The button is a toggle with one icon slot, so the accessible name is the
+ only thing saying which half of the toggle it is in."""
+ state = js_function(SOURCE, "setMicState")
+ assert '"aria-label"' in state
+ assert '"Stop recording"' in state and '"Dictate a message"' in state
+ assert js_says(state, "button.title", 'getAttribute("aria-label")')
+
+
+def test_plain_http_is_explained_rather_than_silently_doing_nothing():
+ """`getUserMedia` is undefined on plain http, which a self-hosted install on
+ a LAN address often is. Without the guard the button does nothing at all,
+ forever, with nothing in the console a reader would find -- and the fix is
+ not something the page can apply for them."""
+ start = js_function(SOURCE, "startRecording")
+ guard = start[: start.index("navigator.mediaDevices.getUserMedia({")]
+ assert "!navigator.mediaDevices" in guard
+ assert 'typeof MediaRecorder === "undefined"' in guard
+ assert "HTTPS" in guard
+
+
+def test_a_refused_microphone_is_reported():
+ """Permission can be blocked at the site or at the system, and both reject
+ the promise. Left uncaught it is a button press that does nothing."""
+ start = js_function(SOURCE, "startRecording")
+ assert ".catch(" in start
+ assert "Permission" in start[start.index(".catch(") :]
+
+
+def test_dictation_audio_is_never_stored():
+ """It has no owner, no row, and nothing would ever sweep it. It goes to the
+ transcription endpoint and nowhere else -- an attachment path would give a
+ voice note a permanent home nobody asked for."""
+ assert "/api/audio/transcribe" in CODE
+ assert "/api/files" not in CODE
+
+
+# --- What reaches the composer ------------------------------------------------
+def test_the_transcript_is_appended_rather_than_replacing_what_was_typed():
+ """Dictation is usually finishing a thought that was already half typed.
+ Overwriting is the complaint the terminal's Send was rewritten to fix, and
+ it would be the same complaint here."""
+ insert = js_function(SOURCE, "insertTranscript")
+ assert js_says(insert, "existing", "input.value", "existing"), "the transcript replaces the box"
+ assert "input.value.length" in insert, "the caret is left at the end"
+
+
+def test_writing_into_the_composer_repaints_its_mirror():
+ """The mirror repaints on `input` and after a swap, and this is neither -- so
+ without the call the highlighting sits over the previous text, shifted by
+ however much was just dictated, until the next keystroke."""
+ insert = js_function(SOURCE, "insertTranscript")
+ assert "paintComposer" in insert
+ assert "autosize" in insert
+
+
+# --- Reading a reply aloud ----------------------------------------------------
+def test_there_is_one_audio_element_for_the_page():
+ """Two replies talking over each other is never what was wanted, and a
+ shared element makes that impossible rather than merely unlikely."""
+ player = js_function(SOURCE, "audioPlayer")
+ assert "if (!player)" in player
+ assert CODE.count("new Audio()") == 1
+
+
+def test_a_second_press_stops_it():
+ """The button is the only control there is -- there is no separate stop --
+ so pressing the one that is speaking has to mean stop rather than start
+ again from the beginning."""
+ speak = js_function(SOURCE, "speak")
+ assert "if (speaking === button)" in speak
+ assert speak.index("element.pause()") < speak.index("element.src")
+
+
+def test_a_refused_autoplay_is_not_reported_as_a_failure():
+ """Autoplay policies reject a `play()` the reader did not ask for. That is
+ the browser working as intended, and an error toast for it would fire on
+ every page load where a reply had just finished."""
+ speak = js_function(SOURCE, "speak")
+ tail = speak[speak.index("element.play()") :]
+ assert "markSpeaking(null)" in tail
+ assert "notify" not in tail
+
+
+def test_a_finished_reply_is_read_out_once():
+ """`data-speak-auto` is set on one frame only, and any swap can bring it in
+ -- so this watches them all. The attribute is cleared *before* the reply is
+ played, or a later swap of the same bubble starts it again, and the
+ transcript reads itself out from the top."""
+ arrivals = js_function(SOURCE, "playArrivals")
+ assert arrivals.index('removeAttribute("data-speak-auto")') < arrivals.index("speak(button)")
+ assert '"htmx:afterSettle"' in CODE
+
+
+def test_the_microphone_leaves_idle_before_it_waits_for_permission():
+ """The dispatcher starts a recording whenever the button reads `idle`, and
+ the state only moved once `getUserMedia` had resolved. The browser's
+ permission prompt sits in between, so every click while it was up opened
+ another stream and another MediaRecorder -- and only the last was kept, so
+ stopping released one and left the rest live. The browser's recording
+ indicator then stayed on until the tab was closed.
+ """
+ code = js_code(script("audio.js"))
+ body = js_function(code, "startRecording")
+
+ # Against the *call*, not the capability check above it, which also names
+ # getUserMedia and legitimately comes first.
+ call = body.index("getUserMedia({ audio: true })")
+ assert body.index('setMicState(button, "working")') < call
+ # And a refusal has to put it back, or the button can never be pressed again
+ # -- including by somebody who has just gone and allowed it.
+ catch = body[body.index(".catch(") :]
+ assert 'setMicState(button, "idle")' in catch
diff --git a/tests/test_canvas_ssh.py b/tests/test_canvas_ssh.py
index 728318a..574fe16 100644
--- a/tests/test_canvas_ssh.py
+++ b/tests/test_canvas_ssh.py
@@ -12,6 +12,9 @@ import pytest
from lembas.services.agent import ssh as ssh_service
from lembas.services.agent.base import Conflict, ExecError
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..31aa7a0
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,140 @@
+"""The four commands, none of which had a test.
+
+`create-admin` is the documented way back into an instance whose administrator
+account has been lost, and the bootstrap step `README.md` tells somebody to run
+when scripting a deployment. It had never been executed by anything but a
+person, which is the worst place to discover that a command does not work: the
+moment you cannot get in.
+
+Driven through typer's `CliRunner` rather than by calling the functions, so the
+options, the prompts and the exit codes are part of what is under test -- a
+`typer.Exit(1)` that the shell reads as success is a script that carries on
+after a failure.
+"""
+
+from __future__ import annotations
+
+from typer.testing import CliRunner
+
+from lembas import __version__
+from lembas.cli import app
+
+runner = CliRunner()
+
+
+def test_secret_key_prints_something_usable():
+ """It is pasted straight into `lembas.env`, so it has to be one line with no
+ surprises in it. A key with a newline silently truncates the variable."""
+ result = runner.invoke(app, ["secret-key"])
+
+ assert result.exit_code == 0
+ key = result.stdout.strip()
+ assert len(key) >= 40
+ assert "\n" not in key
+ # Two calls must not agree, or it is not a secret.
+ assert key != runner.invoke(app, ["secret-key"]).stdout.strip()
+
+
+def test_info_says_where_the_data_is(db):
+ """The command somebody runs when they cannot find the database, which is
+ exactly when a wrong answer costs the most."""
+ from lembas.config import settings
+
+ result = runner.invoke(app, ["info"])
+
+ assert result.exit_code == 0
+ assert __version__ in result.stdout
+ assert str(settings.db_path.resolve()) in result.stdout
+ assert "users" in result.stdout
+
+
+def test_info_warns_about_a_generated_secret_key(db):
+ """An instance running on an ephemeral key loses every session and every
+ stored API key on restart. It is the one thing on this screen that is a
+ problem rather than a fact."""
+ from lembas.config import settings
+
+ if not settings.secret_key_is_ephemeral: # pragma: no cover - depends on env
+ return
+ assert "GENERATED" in runner.invoke(app, ["info"]).stdout
+
+
+def test_create_admin_makes_an_administrator(db):
+ """The way back in. It has to produce an account that can actually sign in,
+ so the password is checked by hashing rather than by trusting the message."""
+ from sqlalchemy import select
+
+ from lembas.db.models import ROLE_ADMIN, User
+ from lembas.security.passwords import verify_password
+
+ result = runner.invoke(
+ app,
+ [
+ "create-admin",
+ "--email", "Frodo@Shire.test",
+ "--name", "Frodo",
+ "--password", "speak-friend-and-enter",
+ ],
+ )
+
+ assert result.exit_code == 0, result.stdout
+ user = db.scalar(select(User).where(User.email == "frodo@shire.test"))
+ assert user is not None, "the address is lowercased, so look for it that way"
+ assert user.role == ROLE_ADMIN
+ assert user.active
+ assert verify_password("speak-friend-and-enter", user.password_hash)
+
+
+def test_create_admin_promotes_an_account_that_already_exists(db):
+ """Its whole purpose in the lost-password case: the account is there, it is
+ just no longer an administrator or no longer has a password anybody knows.
+ A second account with the same address would be no way back in at all."""
+ from sqlalchemy import func, select
+
+ from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
+ from lembas.security.passwords import verify_password
+
+ db.add(
+ User(
+ email="sam@shire.test",
+ name="Sam",
+ password_hash="not-a-real-hash", # noqa: S106
+ role=ROLE_USER,
+ active=False,
+ )
+ )
+ db.commit()
+
+ result = runner.invoke(
+ app,
+ [
+ "create-admin",
+ "--email", "sam@shire.test",
+ "--name", "Samwise",
+ "--password", "second-breakfast-please",
+ ],
+ )
+
+ assert result.exit_code == 0, result.stdout
+ assert db.scalar(select(func.count()).select_from(User)) == 1, "it made a second one"
+ db.expire_all()
+ user = db.scalar(select(User).where(User.email == "sam@shire.test"))
+ assert user.role == ROLE_ADMIN
+ assert user.active, "a deactivated account has to be let back in, or this fixes nothing"
+ assert verify_password("second-breakfast-please", user.password_hash)
+
+
+def test_create_admin_refuses_a_password_that_would_be_rejected_elsewhere(db):
+ """And exits non-zero, so a deployment script stops rather than carrying on
+ believing it has an administrator."""
+ from sqlalchemy import select
+
+ from lembas.db.models import User
+
+ result = runner.invoke(
+ app,
+ ["create-admin", "--email", "x@shire.test", "--name", "X", "--password", "short"],
+ )
+
+ assert result.exit_code == 1
+ assert db.scalar(select(User)) is None
diff --git a/tests/test_composer_js.py b/tests/test_composer_js.py
new file mode 100644
index 0000000..1847cef
--- /dev/null
+++ b/tests/test_composer_js.py
@@ -0,0 +1,287 @@
+"""The composer's typing affordances, checked without a runtime.
+
+There is no JavaScript test runner here and hard rule 1 keeps Node out of the
+project, so the behaviour is driven by hand under a DOM stub before committing.
+That is not a nicety, and this file is the reason it is written down: `refresh()`
+wrote to `list` *before* anything had built it, so the first `/` or `@` ever
+typed threw on a null and took the whole handler with it. The menu never
+appeared, in any browser, for the entire life of the feature, and `node --check`
+parses that file happily.
+
+What the suite can pin are the properties that failure depended on, and the ones
+beside it whose absence is equally silent: a listener registered in a phase the
+event does not reach, a repaint bound to the htmx event that fires too early,
+and a mirror sized differently from the box it sits behind. Every one of those
+looks exactly like working.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import lembas
+
+from .conftest import js_code, js_function, js_says, script
+
+SOURCE = script("composer.js")
+CODE = js_code(SOURCE)
+CSS = (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text(encoding="utf-8")
+
+
+def _rule(css: str, opening: str) -> str:
+ """One declaration block, by the text that opens it.
+
+ The match has to *start* a rule: `.composer__mirror .tok-command {` appears
+ twice, once as the tail of a grouped selector, and reading the group's block
+ when the single one was asked for is a test that answers a question nobody
+ put.
+ """
+ at = -1
+ while True:
+ at = css.index(opening, at + 1)
+ if not css[:at].rstrip().endswith(","):
+ return css[at : css.index("}", at)]
+
+
+# --- The menu, and the null it used to throw on -------------------------------
+def test_the_menu_is_built_before_anything_writes_to_it():
+ """The bug this module exists for. `refresh()` sets `list.innerHTML` and
+ appends to it, and `list` is null until `build()` has run -- so with the
+ build left to `show()`, which is called *after* those writes, the first
+ keystroke threw and the menu never opened for anybody.
+
+ Asserted as the order inside `refresh`, with comments stripped: the file
+ explains this at length in prose, and prose satisfies a substring test.
+ """
+ refresh = js_function(SOURCE, "refresh")
+
+ assert "build()" in refresh, "refresh no longer builds the menu"
+ assert refresh.index("build()") < refresh.index("list"), (
+ "something reads or writes `list` before the menu exists"
+ )
+
+
+def test_building_it_twice_costs_nothing():
+ """Which is what lets `refresh` and `show` both call it unconditionally.
+ Without the guard, every keystroke would insert another menu."""
+ assert js_function(SOURCE, "build").index("if (menu) return;") < 40
+
+
+def test_the_late_mention_reply_checks_the_menu_is_still_there():
+ """A mention list is a round trip late, and the caret may have moved off the
+ token -- or the menu may have been dismissed, which drops `list`. Writing
+ into it anyway is the same null a second time, on a slower path."""
+ load = js_function(SOURCE, "loadMentions")
+ assert load.index("!list") < load.index("list.innerHTML")
+ # And the kind, or a reply for `@` lands in a menu now showing commands.
+ assert 'kind !== "@"' in load
+
+
+def test_choosing_with_the_mouse_holds_the_caret():
+ """`mousedown` is cancelled inside the menu. Without it the composer loses
+ focus before the click lands, and the caret position the choice is about to
+ be written at is already gone -- so the token is replaced at the wrong
+ offset, or not at all."""
+ build = js_function(SOURCE, "build")
+ assert '"mousedown"' in build
+ assert build.index('"mousedown"') < build.index('"click"')
+ assert "preventDefault" in build
+
+
+# --- Listeners, and the phases they must be in --------------------------------
+def test_the_key_listener_is_registered_in_the_capture_phase():
+ """app.js already has a document-level Enter handler that submits the form,
+ and listeners on the same element in the same phase fire in registration
+ order -- app.js loads first. In the bubble phase this one would never get to
+ say "that Enter chose a menu item, it did not send the message", so choosing
+ from the menu would send the half-typed line as well.
+
+ The property rather than the markup, for the reason `tests/test_ui_js.py`
+ gives: a trigger bound where the event does not go is silent.
+ """
+ found = re.search(r'"keydown",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
+ assert found, "the keydown listener is gone"
+ assert found.group(1) == "true"
+
+
+def test_the_scroll_listener_is_too():
+ """`scroll` does not bubble. Registered without the third argument this is
+ never called, and the mirror stops following the textarea the moment the box
+ grows past `data-max-height` -- the rectangles then sit over the wrong
+ words, which is the one failure the mirror exists to avoid."""
+ found = re.search(r'"scroll",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
+ assert found, "the scroll listener is gone"
+ assert found.group(1) == "true"
+
+
+def test_the_mirror_repaints_after_the_request_as_well_as_after_the_swap():
+ """htmx fires afterSwap and afterSettle *before* afterRequest, and the
+ composer empties itself from `hx-on::after-request` -- so every repaint
+ bound to the first two ran while the box still held the message, and the
+ highlighting sat over an empty field until the next keystroke.
+
+ Both late events go through the same deferred helper, and that is the whole
+ assertion: `paint` bound directly to `reset` reads the value in the same
+ turn the event fires, which is *before* a form's fields are actually
+ cleared, so it paints the text that is about to vanish.
+ """
+ after = re.search(r'addEventListener\("htmx:afterRequest",\s*(\w+)\)', CODE)
+ reset = re.search(r'addEventListener\("reset",\s*(\w+)\)', CODE)
+ assert after and reset, "the late repaints are gone"
+ assert after.group(1) == reset.group(1), "the two late events repaint differently"
+ assert after.group(1) != "paint", "a repaint in the same turn paints the old value"
+
+ deferred = js_function(SOURCE, after.group(1))
+ assert "requestAnimationFrame" in deferred
+ assert "paint" in deferred
+
+ # The early ones stay: a swap that brings a new composer in has to paint it.
+ for event in ("htmx:afterSwap", "htmx:afterSettle"):
+ assert re.search(rf'addEventListener\("{event}",\s*paint\)', CODE), event
+
+
+# --- What may become markup ---------------------------------------------------
+def test_nothing_typed_into_the_box_can_become_markup():
+ """The mirror holds the one string a person controls exactly, character for
+ character. Hard rule 6 covers model output; this is the same rule pointed
+ the other way, and it is the only place in the application where somebody's
+ raw keystrokes are re-rendered as they type.
+
+ `list.innerHTML` in the mention path is deliberately not covered: that is a
+ server-rendered fragment, escaped where it was built.
+ """
+ paint = js_function(SOURCE, "paint")
+ assert "innerHTML" not in paint
+ assert "replaceChildren" in paint
+ assert "createTextNode" in paint
+ assert "textContent" in js_function(SOURCE, "token")
+
+
+# --- The mirror and the box are one shape -------------------------------------
+METRICS = (
+ "font-family",
+ "font-size",
+ "line-height",
+ "letter-spacing",
+ "padding",
+ "white-space",
+ "overflow-wrap",
+ "word-break",
+ "width",
+)
+
+
+def test_the_mirror_and_the_box_are_sized_by_one_rule():
+ """A textarea cannot style its own contents, so the mirror sits behind it
+ holding the same text with every character transparent. Every property that
+ decides where a character lands has to be declared once, for both: a single
+ difference and the rectangles slide off the words they are marking, and the
+ drift grows with the length of the line rather than showing up as an obvious
+ misalignment at the start.
+
+ So the shared rule is asserted to carry all of them, and neither of the
+ per-element rules to redeclare any -- which is the version of this that
+ breaks silently, because a second declaration looks like a local tweak.
+ """
+ shared = _rule(CSS, ".composer__input,\n.composer__mirror {")
+ for property_ in METRICS:
+ assert f"{property_}:" in shared, f"{property_} is not shared"
+
+ for selector in ("\n.composer__input {", "\n.composer__mirror {"):
+ own = _rule(CSS, selector)
+ for property_ in METRICS:
+ assert f"{property_}:" not in own, f"{selector.strip()} redeclares {property_}"
+
+
+def test_a_token_restates_what_it_would_otherwise_inherit():
+ """`color: transparent` on the mirror is an inherited value, and any colour
+ a span declares itself beats it. The transcript's rule used to do exactly
+ that from across the file, painting the token in accent-coloured monospace
+ at 0.95em on top of the textarea's own text: doubled, and shifted from that
+ point on because the metrics differ."""
+ tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
+ assert "color: transparent" in tokens
+ assert "font: inherit" in tokens
+
+
+def test_a_token_bleeds_with_a_shadow_rather_than_with_padding():
+ """The rectangle has to be a little wider than the text it sits behind.
+ Padding and a negative margin do that by *moving the glyph*, which is the
+ one thing nothing in the mirror is allowed to do -- a shadow cannot."""
+ tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
+ assert "padding: 0" in tokens
+ assert "margin: 0" in tokens
+
+ for selector in (".composer__mirror .tok-mention {", ".composer__mirror .tok-command {"):
+ rule = _rule(CSS, selector)
+ assert "box-shadow" in rule, f"{selector} does not bleed"
+ assert "padding" not in rule, f"{selector} moves the glyph"
+ assert "margin" not in rule, f"{selector} moves the glyph"
+
+
+def test_every_token_style_names_where_it_applies():
+ """A rule written for one context matches every context. `.tok-mention`
+ unscoped was written for the transcript and also hit the mirror's spans,
+ which is the bug above. Both places qualify their selectors now, and an
+ unqualified one added later would do it again with nothing to notice."""
+ for line in CSS.splitlines():
+ if ".tok-" not in line:
+ continue
+ selector = line.strip()
+ assert selector.startswith((".msg ", ".composer__mirror ")), selector
+
+
+# --- Slash commands, from the composer's side ---------------------------------
+def test_an_unrecognised_slash_is_never_swallowed():
+ """Eating somebody's message because it began with a slash is a far worse
+ failure than an unknown command. Enter only cancels the send when the value
+ resolves to a command -- so the `preventDefault` has to sit *inside* that
+ test, not beside it."""
+ window = CODE[CODE.index('event.key === "Enter" && !event.shiftKey') :][:600]
+ assert window.index("commandIn(") < window.index("preventDefault")
+ assert "if (command) {" in window
+
+
+def test_choosing_a_command_clears_the_box_before_running_it():
+ """A command is the whole message, never part of one. `/help` left sitting
+ in the box after running means the next Enter runs it again -- which is what
+ driving this under a DOM stub found, along with Tab not completing."""
+ choose = js_function(SOURCE, "choose")
+ assert js_says(choose, "input.value", '""', "runCommand")
+
+
+def test_tab_completes_and_enter_runs():
+ """The difference matters for a command that takes an argument: Tab writes
+ `/mode ` and leaves the caret after it, Enter runs what is there. Both are
+ one call, so the key is what decides -- and `choose` has to branch on it
+ before it reaches the run."""
+ assert js_says(CODE, "choose(all[active]", 'event.key === "Tab"')
+ choose = js_function(SOURCE, "choose")
+ assert choose.index("if (complete)") < choose.index("runCommand")
+
+
+def test_the_at_menu_works_on_a_page_without_commands_js():
+ """`@` and `/` share one menu but not one dependency. commands.js is absent
+ from some pages and may fail to parse on any of them, and either would
+ otherwise take the mention picker down with it."""
+ fallback = js_function(SOURCE, "commands")
+ assert "window.lembasCommands ||" in fallback
+ for method in ("list:", "find:", "run:"):
+ assert method in fallback, f"the fallback has no {method}"
+
+ # Read at call time rather than captured once, or a page whose commands.js
+ # loads second gets the fallback forever.
+ for helper in ("commandList", "commandIn", "runCommand"):
+ assert "commands()." in js_function(SOURCE, helper)
+
+
+def test_the_directory_is_read_from_the_field_that_is_submitted():
+ """The chip beside the connection shows the directory's own name so it stops
+ eating the composer's one row; the whole path lives in the hidden field. A
+ mention picker reading the label would ask the server about `worker` rather
+ than about `/srv/projects/…/worker` -- shortening a label must never shorten
+ a value, on this path either."""
+ context = js_function(SOURCE, "context")
+ assert "[data-dir-value]" in context
+ assert "data-dir-label" not in context
diff --git a/tests/test_library_routes.py b/tests/test_library_routes.py
new file mode 100644
index 0000000..1da671d
--- /dev/null
+++ b/tests/test_library_routes.py
@@ -0,0 +1,981 @@
+"""The library at the HTTP boundary: who may read, who may write, who may delete.
+
+`tests/test_library.py` covers the four stores by calling the services. That
+leaves untested the layer where the answers to "is this yours?" actually live:
+the router's single `library.use` dependency, the `sharing.can_write` check in
+front of every write, and the three ways a route says no -- a redirect to the
+login page, a 403, and a 404.
+
+Every assertion here is on a database row. A route that answers 403 and changes
+the row anyway, or answers 200 and changes nothing, is exactly the failure this
+file exists to catch, and neither is visible from a status code alone.
+
+**404 versus 403 is a real distinction here, not an accident.** 404 means "you
+cannot see this at all", and it is deliberately the same answer a missing id
+gets, so probing ids tells a stranger nothing. 403 means "you can see it and it
+is not yours to change" -- reachable only through a share, which grants reading
+only. The deletes fold both into 404, and the tests below say so where it
+happens rather than pretending it is uniform.
+"""
+
+from __future__ import annotations
+
+import re
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+from lembas.db.models import (
+ Document,
+ KnowledgeBase,
+ Memory,
+ Note,
+ Share,
+ Skill,
+ SkillRevision,
+ User,
+)
+from lembas.services import settings_store, sharing
+from lembas.services.library import documents as documents_service
+from lembas.services.library import memories as memories_service
+from lembas.services.library import notes as notes_service
+from lembas.services.library import skills as skills_service
+
+STRANGER = {"name": "Sam", "email": "sam@shire.test", "password": "gardening-is-hard"}
+
+
+# --- People -------------------------------------------------------------------
+@pytest.fixture
+def owner(db, registered) -> User:
+ """The first account, which is therefore the administrator."""
+ return db.scalars(select(User).order_by(User.created_at)).first()
+
+
+@pytest.fixture
+def stranger(client, registered) -> TestClient:
+ """A second signed-in account, in its own browser.
+
+ Registered second on purpose: the first account becomes the administrator,
+ and an administrator bypasses every permission. Ownership questions have to
+ be asked of somebody who does not.
+ """
+ other = TestClient(client.app)
+ response = other.post("/auth/register", data=STRANGER, follow_redirects=False)
+ assert response.status_code == 303, response.text
+ return other
+
+
+@pytest.fixture
+def stranger_user(db, stranger) -> User:
+ return db.scalar(select(User).where(User.email == STRANGER["email"]))
+
+
+@pytest.fixture
+def public(monkeypatch):
+ """Make every hostname resolve to a public address, resolving nothing."""
+ import socket
+
+ monkeypatch.setattr(
+ socket, "getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
+ )
+
+
+# --- Rows ---------------------------------------------------------------------
+def _base(db, owner: User, name: str = "Papers") -> KnowledgeBase:
+ return documents_service.create_base(db, owner=owner, name=name)
+
+
+def _document(db, owner: User, base: KnowledgeBase | None = None, **kwargs) -> Document:
+ fields = {
+ "payload": b"The west gate of Moria was built by Narvi.",
+ "filename": "gate.txt",
+ "title": "Gate",
+ **kwargs,
+ }
+ return documents_service.store_upload(db, owner=owner, base=base, **fields)
+
+
+def _note(db, owner: User, title: str = "Mellon", body: str = "Speak friend.") -> Note:
+ return notes_service.create(db, owner=owner, title=title, body=body)
+
+
+def _skill(db, owner: User, name: str = "weekly-report", body: str = "Step one.") -> Skill:
+ return skills_service.create(
+ db, owner=owner, name=name, description="When a week ends.", body=body
+ )
+
+
+def _memory(db, owner: User, content: str = "Prefers metric units.") -> Memory:
+ return memories_service.add(db, owner=owner, content=content)
+
+
+# --- The one gate in front of everything --------------------------------------
+def _routes():
+ """Every route this router serves, with its path parameters filled in.
+
+ Read off the router rather than listed by hand, so a route added tomorrow is
+ covered by the permission test the day it appears.
+ """
+ from lembas.api import library
+
+ for route in library.router.routes:
+ for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
+ yield method, re.sub(r"\{[^}]+\}", "no-such-id", route.path)
+
+
+def test_every_route_is_behind_library_use(db, client, stranger):
+ """The gate is one dependency on the router, so a route added without a
+ thought inherits it -- and a route moved to another router silently loses it.
+
+ Without this, somebody with the library switched off keeps a working set of
+ URLs: the pages vanish from their sidebar and every one of them still answers.
+ Asserted against a fabricated id so nothing here depends on what exists.
+ """
+ settings_store.update(db, {"default_permissions": {"library.use": False}})
+
+ refused = []
+ for method, path in _routes():
+ response = stranger.request(method, path, follow_redirects=False)
+ refused.append((method, path, response.status_code))
+
+ assert [row for row in refused if row[2] != 403] == []
+ assert len(refused) >= 28, "the router lost routes; this test is no longer covering them"
+
+
+def test_the_gate_is_checked_before_the_write_happens(db, client, stranger, stranger_user):
+ """A guard that runs after the row is written is not a guard. Asserted on the
+ absence of the note rather than on the status code, because a 403 returned
+ over a completed write looks identical from the outside."""
+ settings_store.update(db, {"default_permissions": {"library.use": False}})
+
+ stranger.post(
+ "/api/library/notes", data={"title": "Smuggled", "body": "x"}, follow_redirects=False
+ )
+
+ db.expire_all()
+ assert db.scalars(select(Note)).all() == []
+
+
+def test_an_administrator_bypasses_the_permission(db, client, registered, owner):
+ """Deliberate, and the same rule the rest of the codebase follows: an admin
+ can grant themselves the permission in two clicks, so withholding it is
+ theatre. Worth pinning because the ownership tests below rely on the
+ *opposite* being true of sharing."""
+ settings_store.update(db, {"default_permissions": {"library.use": False}})
+
+ client.post("/api/library/notes", data={"title": "Mine", "body": "x"}, follow_redirects=False)
+
+ db.expire_all()
+ assert [n.title for n in db.scalars(select(Note))] == ["Mine"]
+
+
+def test_signed_out_changes_nothing(db, client, registered, owner):
+ """A delete needs a session. Without this, a link somebody was sent removes
+ a note from an account nobody is signed in to."""
+ note = _note(db, owner)
+ nobody = TestClient(client.app)
+
+ nobody.post(f"/api/library/notes/{note.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(Note, note.id) is not None
+
+
+# --- Reading somebody else's work ---------------------------------------------
+def test_a_strangers_note_is_not_readable(db, client, stranger, owner):
+ """404 rather than 403: an id somebody does not own must be indistinguishable
+ from an id that does not exist, or the detail page becomes an oracle for
+ which notes are on the instance."""
+ note = _note(db, owner, title="Private", body="mellon")
+
+ response = stranger.get(f"/library/notes/{note.id}")
+
+ assert response.status_code == 404
+ assert "mellon" not in response.text
+
+
+def test_a_strangers_skill_is_not_readable(db, client, stranger, owner):
+ skill = _skill(db, owner, body="The secret procedure.")
+
+ response = stranger.get(f"/library/skills/{skill.id}")
+
+ assert response.status_code == 404
+ assert "The secret procedure." not in response.text
+
+
+def test_a_strangers_base_is_not_readable(db, client, stranger, owner):
+ base = _base(db, owner, name="Contracts")
+ _document(db, owner, base, title="The lease")
+
+ response = stranger.get(f"/library/knowledge/{base.id}")
+
+ assert response.status_code == 404
+ assert "The lease" not in response.text
+
+
+def test_a_strangers_document_is_not_readable(db, client, stranger, owner):
+ """Visibility comes from the base, never the document -- so this is also the
+ check that `documents_service.get` resolves through the base rather than
+ trusting an id it was handed."""
+ document = _document(db, owner, _base(db, owner))
+
+ response = stranger.get(f"/library/knowledge/document/{document.id}")
+
+ assert response.status_code == 404
+
+
+def test_a_strangers_document_file_is_not_served(db, client, stranger, owner):
+ """The bytes, not the page. A route that authorises the detail view and
+ serves the file to anyone is a library that is private in the UI only."""
+ document = _document(db, owner, _base(db, owner), payload=b"Narvi built it.")
+
+ response = stranger.get(f"/api/library/documents/{document.id}/content")
+
+ assert response.status_code == 404
+ assert b"Narvi built it." not in response.content
+
+
+def test_an_administrator_cannot_read_somebody_elses_note(db, client, registered, owner):
+ """`permissions.resolve` gives an admin everything and `services/sharing.py`
+ deliberately has no admin branch. Reading somebody's private notes is not
+ configuration, and being able to reach the database is not being invited."""
+ other = User(email="merry@shire.test", name="Merry", password_hash="x") # noqa: S106
+ db.add(other)
+ db.commit()
+ note = _note(db, other, title="Theirs", body="not for you")
+
+ assert owner.is_admin is True
+ response = client.get(f"/library/notes/{note.id}")
+
+ assert response.status_code == 404
+ assert "not for you" not in response.text
+
+
+def test_a_shared_note_is_readable(db, client, stranger, stranger_user, owner):
+ """The other half of the rule above: a grant does reach the detail page.
+ Without this passing, the refusals could be a route that refuses everybody."""
+ note = _note(db, owner, title="Shared", body="Speak friend.")
+ sharing.set_grants(db, note, user_ids=[stranger_user.id], group_ids=[])
+
+ response = stranger.get(f"/library/notes/{note.id}")
+
+ assert response.status_code == 200
+ assert "Speak friend." in response.text
+
+
+# --- Writing somebody else's work ---------------------------------------------
+def test_a_stranger_cannot_edit_a_note(db, client, stranger, owner):
+ note = _note(db, owner, title="Mine", body="Original.")
+
+ stranger.post(
+ f"/api/library/notes/{note.id}",
+ data={"title": "Theirs now", "body": "Rewritten."},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(Note, note.id).body == "Original."
+
+
+def test_a_stranger_cannot_delete_a_note(db, client, stranger, owner):
+ note = _note(db, owner)
+
+ stranger.post(f"/api/library/notes/{note.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(Note, note.id) is not None
+
+
+def test_a_shared_note_is_read_only(db, client, stranger, stranger_user, owner):
+ """Sharing grants reading and nothing else. Two people editing one note with
+ no history and no merge is worse than the inconvenience of copying it.
+
+ The two refusals differ and that is what this pins: `update` answers 403
+ because the reader can see the note, while `delete` folds "not visible" and
+ "not yours" into one 404. Both refuse; only the wording differs.
+ """
+ note = _note(db, owner, title="Shared", body="Original.")
+ sharing.set_grants(db, note, user_ids=[stranger_user.id], group_ids=[])
+
+ edited = stranger.post(
+ f"/api/library/notes/{note.id}", data={"body": "Rewritten."}, follow_redirects=False
+ )
+ removed = stranger.post(f"/api/library/notes/{note.id}/delete", follow_redirects=False)
+
+ assert edited.status_code == 403
+ assert removed.status_code == 404
+ db.expire_all()
+ assert db.get(Note, note.id).body == "Original."
+
+
+def test_a_stranger_cannot_edit_a_skill(db, client, stranger, owner):
+ """A skill is instructions a model follows without being asked twice.
+ Somebody else editing one is somebody else's words in your model's prompt."""
+ skill = _skill(db, owner, body="Original.")
+
+ stranger.post(
+ f"/api/library/skills/{skill.id}",
+ data={"description": "Changed.", "body": "Rewritten.", "enabled": "on"},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(Skill, skill.id).body == "Original."
+
+
+def test_a_stranger_cannot_delete_a_skill(db, client, stranger, owner):
+ skill = _skill(db, owner)
+
+ stranger.post(f"/api/library/skills/{skill.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(Skill, skill.id) is not None
+
+
+def test_a_stranger_cannot_revert_a_skill(db, client, stranger, owner):
+ """Revert is a write dressed as history: it replaces the body with an older
+ one. A route that checked only "does this revision exist" would let anybody
+ roll back anybody's skill."""
+ skill = _skill(db, owner, body="First.")
+ skills_service.update(db, skill, body="Second.")
+ revision = skill.revisions[0]
+
+ stranger.post(
+ f"/api/library/skills/{skill.id}/revert/{revision.id}", follow_redirects=False
+ )
+
+ db.expire_all()
+ assert db.get(Skill, skill.id).body == "Second."
+
+
+def test_a_stranger_cannot_rename_a_base(db, client, stranger, owner):
+ base = _base(db, owner, name="Contracts")
+
+ stranger.post(
+ f"/api/library/bases/{base.id}", data={"name": "Theirs"}, follow_redirects=False
+ )
+
+ db.expire_all()
+ assert db.get(KnowledgeBase, base.id).name == "Contracts"
+
+
+def test_a_shared_base_cannot_be_renamed_or_deleted(db, client, stranger, stranger_user, owner):
+ """A base is the unit of sharing, so somebody who was given one has the
+ strongest claim to being able to change it -- and still cannot. Renaming it
+ would change what it says on the owner's own page."""
+ base = _base(db, owner, name="Contracts")
+ _document(db, owner, base)
+ sharing.set_grants(db, base, user_ids=[stranger_user.id], group_ids=[])
+
+ renamed = stranger.post(
+ f"/api/library/bases/{base.id}", data={"name": "Theirs"}, follow_redirects=False
+ )
+ removed = stranger.post(f"/api/library/bases/{base.id}/delete", follow_redirects=False)
+
+ assert renamed.status_code == 403
+ assert removed.status_code == 404
+ db.expire_all()
+ assert db.get(KnowledgeBase, base.id).name == "Contracts"
+ assert len(db.scalars(select(Document)).all()) == 1
+
+
+def test_a_stranger_cannot_delete_a_base(db, client, stranger, owner):
+ """Not visible at all, so 404 -- and the documents inside it survive, which
+ is the part that matters: `delete_base` takes its contents with it."""
+ base = _base(db, owner, name="Contracts")
+ _document(db, owner, base)
+
+ stranger.post(f"/api/library/bases/{base.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(KnowledgeBase, base.id) is not None
+ assert len(db.scalars(select(Document)).all()) == 1
+
+
+def test_a_stranger_cannot_edit_a_document(db, client, stranger, owner):
+ document = _document(db, owner, _base(db, owner), title="The lease")
+
+ stranger.post(
+ f"/api/library/documents/{document.id}",
+ data={"title": "Theirs"},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(Document, document.id).title == "The lease"
+
+
+def test_a_stranger_cannot_delete_a_document(db, client, stranger, owner):
+ """The file on disk as well as the row. A delete that removed the bytes and
+ then refused would leave the owner with a document that cannot be opened."""
+ document = _document(db, owner, _base(db, owner))
+ stored = documents_service.stored_path(document.stored_name)
+ assert stored is not None
+
+ stranger.post(f"/api/library/documents/{document.id}/delete", follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(Document, document.id) is not None
+ assert stored.is_file()
+
+
+def test_a_document_in_a_shared_base_is_readable_and_not_writable(
+ db, client, stranger, stranger_user, owner
+):
+ """The one store whose visibility does not come from itself. A reader who
+ can see a document through somebody else's base must not be able to retitle
+ it or delete it out of that base."""
+ base = _base(db, owner, name="Contracts")
+ document = _document(db, owner, base, title="The lease")
+ sharing.set_grants(db, base, user_ids=[stranger_user.id], group_ids=[])
+
+ assert stranger.get(f"/library/knowledge/document/{document.id}").status_code == 200
+ edited = stranger.post(
+ f"/api/library/documents/{document.id}", data={"title": "Theirs"}, follow_redirects=False
+ )
+ removed = stranger.post(
+ f"/api/library/documents/{document.id}/delete", follow_redirects=False
+ )
+
+ assert edited.status_code == 403
+ assert removed.status_code == 404
+ db.expire_all()
+ assert db.get(Document, document.id).title == "The lease"
+
+
+def test_a_stranger_cannot_edit_a_memory(db, client, stranger, owner):
+ """Memories are injected into every request. Somebody else writing one is
+ somebody else putting a standing instruction in front of your model."""
+ memory = _memory(db, owner, content="Prefers metric units.")
+
+ response = stranger.post(
+ f"/api/library/memories/{memory.id}", data={"content": "Trusts strangers."},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 404
+ db.expire_all()
+ assert db.get(Memory, memory.id).content == "Prefers metric units."
+
+
+def test_a_stranger_cannot_delete_a_memory(db, client, stranger, owner):
+ """Memories are not shareable at all, so there is no 403 case here: anything
+ that is not yours is invisible."""
+ memory = _memory(db, owner)
+
+ response = stranger.post(
+ f"/api/library/memories/{memory.id}/delete", follow_redirects=False
+ )
+
+ assert response.status_code == 404
+ db.expire_all()
+ assert db.get(Memory, memory.id) is not None
+
+
+# --- Deletes that are supposed to work ----------------------------------------
+def test_deleting_a_note_forgets_its_grants(db, client, registered, owner):
+ """`Share` carries no foreign key in either direction, so nothing cascades.
+ A grant left behind names an id nothing owns, and would grant access to
+ whoever next received it."""
+ note = _note(db, owner)
+ sharing.set_grants(
+ db, note, user_ids=[], group_ids=["a-group-that-will-outlive-this"]
+ )
+
+ client.post(f"/api/library/notes/{note.id}/delete", follow_redirects=False)
+
+ db.expunge_all()
+ assert db.get(Note, note.id) is None
+ assert db.scalars(select(Share)).all() == []
+
+
+def test_deleting_a_skill_takes_its_history_and_its_grants(db, client, registered, owner):
+ """Revisions are the entire safety story for a model rewriting its own
+ instructions. Orphaned ones are rows nothing can ever reach again."""
+ skill = _skill(db, owner, body="First.")
+ skills_service.update(db, skill, body="Second.")
+ sharing.set_grants(db, skill, user_ids=[], group_ids=["team"])
+ assert db.scalars(select(SkillRevision)).all()
+
+ client.post(f"/api/library/skills/{skill.id}/delete", follow_redirects=False)
+
+ db.expunge_all()
+ assert db.get(Skill, skill.id) is None
+ assert db.scalars(select(SkillRevision)).all() == []
+ assert db.scalars(select(Share)).all() == []
+
+
+def test_deleting_a_document_removes_the_file(db, client, registered, owner):
+ """The row going and the bytes staying is a disk that fills with files
+ nothing references and nothing will ever delete."""
+ document = _document(db, owner, _base(db, owner))
+ stored = documents_service.stored_path(document.stored_name)
+ assert stored is not None and stored.is_file()
+
+ client.post(f"/api/library/documents/{document.id}/delete", follow_redirects=False)
+
+ db.expunge_all()
+ assert db.get(Document, document.id) is None
+ assert not stored.is_file()
+
+
+def test_deleting_a_base_takes_its_documents_and_their_files(db, client, registered, owner):
+ """A base is a place, not a label: leaving its contents behind would need an
+ "unfiled" concept that exists only to hold the wreckage of deletes. The
+ other base is here because a delete that took everything would look correct
+ from inside a single-base test."""
+ doomed = _base(db, owner, name="Contracts")
+ kept = _base(db, owner, name="Recipes")
+ inside = _document(db, owner, doomed)
+ elsewhere = _document(db, owner, kept)
+ stored = documents_service.stored_path(inside.stored_name)
+ sharing.set_grants(db, doomed, user_ids=[], group_ids=["team"])
+
+ client.post(f"/api/library/bases/{doomed.id}/delete", follow_redirects=False)
+
+ db.expunge_all()
+ assert db.get(KnowledgeBase, doomed.id) is None
+ assert db.get(Document, inside.id) is None
+ assert not stored.is_file()
+ assert db.get(Document, elsewhere.id) is not None
+ assert db.scalars(select(Share)).all() == []
+
+
+def test_deleting_a_memory_works_for_its_owner(db, client, registered, owner):
+ memory = _memory(db, owner)
+
+ client.post(f"/api/library/memories/{memory.id}/delete", follow_redirects=False)
+
+ db.expunge_all()
+ assert db.get(Memory, memory.id) is None
+
+
+def test_reverting_a_skill_restores_the_body_and_keeps_the_way_back(
+ db, client, registered, owner
+):
+ """Going back has to be undoable too, or a revert made by mistake is the one
+ change in this store with no record of what it replaced."""
+ skill = _skill(db, owner, body="First.")
+ skills_service.update(db, skill, body="Second.")
+ revision = skill.revisions[0]
+
+ client.post(
+ f"/api/library/skills/{skill.id}/revert/{revision.id}", follow_redirects=False
+ )
+
+ db.expire_all()
+ restored = db.get(Skill, skill.id)
+ assert restored.body == "First."
+ assert [r.body for r in restored.revisions] == ["Second.", "First."]
+
+
+def test_a_revision_belonging_to_another_skill_is_refused(db, client, registered, owner):
+ """The revision id is a second, independent handle on somebody's data. Without
+ the `revision.skill_id != skill.id` check, one skill could be overwritten with
+ the contents of any other -- including one shared to you and not yours."""
+ victim = _skill(db, owner, name="victim", body="Victim body.")
+ donor = _skill(db, owner, name="donor", body="Donor body.")
+ skills_service.update(db, donor, body="Donor changed.")
+ foreign = donor.revisions[0]
+
+ response = client.post(
+ f"/api/library/skills/{victim.id}/revert/{foreign.id}", follow_redirects=False
+ )
+
+ assert response.status_code == 404
+ db.expire_all()
+ assert db.get(Skill, victim.id).body == "Victim body."
+
+
+# --- Bases --------------------------------------------------------------------
+def test_creating_a_base_writes_it_to_the_signed_in_account(db, client, registered, owner):
+ client.post(
+ "/api/library/bases", data={"name": " Contracts ", "description": "Leases."},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ bases = db.scalars(select(KnowledgeBase)).all()
+ assert [(b.name, b.owner_id) for b in bases] == [("Contracts", owner.id)]
+
+
+def test_a_duplicate_base_name_makes_no_second_row(db, client, registered, owner):
+ """The name is what somebody picks from when filing a document. Two called
+ "Contracts" is a choice nobody can make correctly."""
+ _base(db, owner, name="Contracts")
+
+ response = client.post(
+ "/api/library/bases", data={"name": "Contracts"}, follow_redirects=False
+ )
+
+ db.expire_all()
+ assert len(db.scalars(select(KnowledgeBase)).all()) == 1
+ assert response.status_code == 303
+ assert "error=" in response.headers["location"]
+
+
+def test_two_people_may_each_have_a_base_of_the_same_name(db, client, stranger, owner):
+ """Uniqueness is per owner. A shared instance where the first person to say
+ "My documents" takes the name from everybody else is unusable."""
+ _base(db, owner, name="Contracts")
+
+ stranger.post("/api/library/bases", data={"name": "Contracts"}, follow_redirects=False)
+
+ db.expire_all()
+ assert len(db.scalars(select(KnowledgeBase)).all()) == 2
+
+
+def test_renaming_a_base_keeps_its_documents(db, client, registered, owner):
+ base = _base(db, owner, name="Contracts")
+ document = _document(db, owner, base)
+
+ client.post(
+ f"/api/library/bases/{base.id}",
+ data={"name": "Leases", "description": "Signed ones."},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(KnowledgeBase, base.id).name == "Leases"
+ assert db.get(Document, document.id).base_id == base.id
+
+
+def test_an_empty_name_leaves_the_base_named(db, client, registered, owner):
+ """A rename form submitted with the field cleared must not produce a base
+ with no name, which is a row nobody can pick out of a list."""
+ base = _base(db, owner, name="Contracts")
+
+ client.post(f"/api/library/bases/{base.id}", data={"name": " "}, follow_redirects=False)
+
+ db.expire_all()
+ assert db.get(KnowledgeBase, base.id).name == "Contracts"
+
+
+# --- Documents ----------------------------------------------------------------
+def test_an_upload_lands_in_the_base_it_named(db, client, registered, owner):
+ base = _base(db, owner, name="Contracts")
+
+ client.post(
+ "/api/library/documents",
+ data={"title": "The lease", "base_id": base.id},
+ files={"file": ("lease.txt", b"Signed at Bag End.", "text/plain")},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ stored = db.scalars(select(Document)).all()
+ assert [(d.title, d.base_id) for d in stored] == [("The lease", base.id)]
+
+
+def test_an_upload_naming_an_invisible_base_goes_to_your_own(db, client, stranger, owner):
+ """`get_base` cannot see it, so the request falls back to the uploader's own
+ default base. The property that matters is that nothing lands in somebody
+ else's library -- and the redirect goes to a base the uploader can open, so
+ the document is not silently lost either."""
+ theirs = _base(db, owner, name="Contracts")
+
+ stranger.post(
+ "/api/library/documents",
+ data={"base_id": theirs.id},
+ files={"file": ("mine.txt", b"My own file.", "text/plain")},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ landed = db.scalars(select(Document)).all()
+ assert len(landed) == 1
+ assert landed[0].base_id != theirs.id
+ assert db.get(KnowledgeBase, landed[0].base_id).owner_id != owner.id
+
+
+def test_an_upload_into_a_base_shared_to_you_is_refused(db, client, stranger, stranger_user, owner):
+ """Visible and still not writable. Adding a document to somebody's base would
+ put your file in front of everybody they shared it with, under their name."""
+ base = _base(db, owner, name="Contracts")
+ sharing.set_grants(db, base, user_ids=[stranger_user.id], group_ids=[])
+
+ response = stranger.post(
+ "/api/library/documents",
+ data={"base_id": base.id},
+ files={"file": ("mine.txt", b"My own file.", "text/plain")},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 403
+ db.expire_all()
+ assert db.scalars(select(Document)).all() == []
+
+
+def test_a_document_can_be_moved_between_your_own_bases(db, client, registered, owner):
+ """Moving is what changes who can see a document, since visibility comes from
+ the base. It has to actually move."""
+ origin = _base(db, owner, name="Inbox")
+ destination = _base(db, owner, name="Contracts")
+ document = _document(db, owner, origin)
+
+ client.post(
+ f"/api/library/documents/{document.id}",
+ data={"title": "The lease", "base_id": destination.id},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(Document, document.id).base_id == destination.id
+
+
+def test_a_document_cannot_be_moved_into_a_base_you_only_read(
+ db, client, stranger, stranger_user, owner
+):
+ """Moving it there would hand it to that base's owner and to everybody they
+ shared it with. The route drops the move and keeps the rest of the save,
+ which is why this asserts on `base_id` rather than on a status code."""
+ theirs = _base(db, owner, name="Contracts")
+ sharing.set_grants(db, theirs, user_ids=[stranger_user.id], group_ids=[])
+ mine = _base(db, stranger_user, name="Mine")
+ document = _document(db, stranger_user, mine)
+
+ stranger.post(
+ f"/api/library/documents/{document.id}",
+ data={"title": "Kept", "base_id": theirs.id},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert db.get(Document, document.id).base_id == mine.id
+
+
+def test_a_documents_file_is_served_as_an_attachment(db, client, registered, owner):
+ """An uploaded .html served inline executes in this origin, with the session
+ cookie. The header is the whole defence, so it is asserted rather than the
+ body."""
+ document = _document(
+ db, owner, _base(db, owner), payload=b"hello ", filename="page.html"
+ )
+
+ response = client.get(f"/api/library/documents/{document.id}/content")
+
+ assert response.status_code == 200
+ assert response.headers["x-content-type-options"] == "nosniff"
+ assert response.headers["content-disposition"].startswith("attachment")
+
+
+def test_a_document_whose_file_is_gone_says_so(db, client, registered, owner):
+ """A stored name that no longer resolves is a 404, not a traceback: files can
+ go missing under a server and the page above this one still has to render."""
+ document = _document(db, owner, _base(db, owner))
+ documents_service.stored_path(document.stored_name).unlink()
+
+ assert client.get(f"/api/library/documents/{document.id}/content").status_code == 404
+
+
+# --- The link route, which reaches out ----------------------------------------
+def test_saving_a_link_refuses_a_loopback_address(db, client, registered, owner):
+ """This route hands a user-supplied URL to a fetcher running on a server that
+ can reach LLeMbas itself, the router, and every other service on the box. The
+ refusal has to happen here, not just in the tests of `fetch`."""
+ response = client.post(
+ "/api/library/documents/link",
+ data={"url": "http://127.0.0.1:8080/admin"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 400
+ db.expire_all()
+ assert db.scalars(select(Document)).all() == []
+
+
+def test_saving_a_link_refuses_a_name_that_resolves_to_loopback(db, client, registered, owner):
+ """The check is on the resolved address. A hostname pointing at 127.0.0.1 is
+ the obvious way past one that only reads the text of the URL."""
+ response = client.post(
+ "/api/library/documents/link", data={"url": "http://localhost/"}, follow_redirects=False
+ )
+
+ assert response.status_code == 400
+ db.expire_all()
+ assert db.scalars(select(Document)).all() == []
+
+
+def test_saving_a_link_refuses_a_redirect_onto_the_server(
+ db, client, registered, owner, mock_http, monkeypatch
+):
+ """Every hop, not just the first. httpx's own following would validate the
+ address somebody typed and then land wherever it was sent."""
+ import socket
+
+ monkeypatch.setattr(
+ socket,
+ "getaddrinfo",
+ lambda host, *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
+ if host == "example.com"
+ else [(2, 1, 6, "", ("127.0.0.1", 80))],
+ )
+ mock_http(lambda _r: httpx.Response(302, headers={"location": "http://127.0.0.1:8080/admin"}))
+
+ response = client.post(
+ "/api/library/documents/link",
+ data={"url": "https://example.com/"},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 400
+ db.expire_all()
+ assert db.scalars(select(Document)).all() == []
+
+
+def test_saving_a_link_keeps_the_page_as_text(db, client, registered, owner, mock_http, public):
+ """The point of keeping a page is what it said. Stored as text rather than
+ HTML, so nothing has to be reduced again on every read."""
+ base = _base(db, owner, name="Reading")
+ mock_http(
+ lambda _r: httpx.Response(
+ 200,
+ headers={"content-type": "text/html"},
+ text=(
+ "Mallorn "
+ "A golden tree.
"
+ ),
+ )
+ )
+
+ client.post(
+ "/api/library/documents/link",
+ data={"url": "https://example.com/mallorn", "base_id": base.id},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ stored = db.scalars(select(Document)).all()
+ assert len(stored) == 1
+ assert stored[0].title == "Mallorn"
+ assert stored[0].extracted_text == "A golden tree."
+ assert stored[0].base_id == base.id
+
+
+def test_the_administrators_switch_reaches_the_link_route(db, client, registered, owner, mock_http):
+ """`allow_private_fetch` is an instance setting, and a route that never read
+ it would leave the switch in the admin page doing nothing at all -- the exact
+ failure this codebase keeps cataloguing."""
+ settings_store.update(db, {"allow_private_fetch": True}, key=settings_store.SEARCH)
+ mock_http(
+ lambda _r: httpx.Response(
+ 200, headers={"content-type": "text/html"}, text="An internal page.
"
+ )
+ )
+
+ client.post(
+ "/api/library/documents/link",
+ data={"url": "http://127.0.0.1:9/notes"},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert [d.extracted_text for d in db.scalars(select(Document))] == ["An internal page."]
+
+
+def test_saving_a_link_into_a_base_shared_to_you_is_refused(
+ db, client, stranger, stranger_user, owner, mock_http, public
+):
+ """The same gate the upload route has. Without it, the fetch is the way round
+ a refusal on the other door into the same base."""
+ base = _base(db, owner, name="Contracts")
+ sharing.set_grants(db, base, user_ids=[stranger_user.id], group_ids=[])
+ mock_http(
+ lambda _r: httpx.Response(200, headers={"content-type": "text/html"}, text="x
")
+ )
+
+ response = stranger.post(
+ "/api/library/documents/link",
+ data={"url": "https://example.com/", "base_id": base.id},
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 403
+ db.expire_all()
+ assert db.scalars(select(Document)).all() == []
+
+
+# --- Route order, which fails silently ----------------------------------------
+def test_the_static_segments_are_not_parsed_as_ids(db, client, registered, owner):
+ """FastAPI matches in registration order, so `/library/notes/new` reaching the
+ detail route would 404 on a note called "new" -- and there would be no way to
+ write one. This has already been a bug once in the model admin."""
+ assert client.get("/library/notes/new").status_code == 200
+ assert client.get("/library/skills/new").status_code == 200
+
+ document = _document(db, owner, _base(db, owner))
+ assert client.get(f"/library/knowledge/document/{document.id}").status_code == 200
+
+
+def test_the_link_route_is_not_parsed_as_a_document_id(db, client, registered, owner):
+ """`/api/library/documents/link` is registered before
+ `/api/library/documents/{document_id}`. Registered after it, saving a page
+ would answer "that document is not available" and nobody would guess why."""
+ response = client.post(
+ "/api/library/documents/link", data={"url": "not-a-url"}, follow_redirects=False
+ )
+
+ assert response.status_code == 400
+
+
+# --- Memory -------------------------------------------------------------------
+def test_an_empty_memory_is_not_recorded(db, client, registered, owner):
+ """A blank line in front of the model on every turn, forever, and no way to
+ tell which of several it is when removing one."""
+ response = client.post("/api/library/memories", data={"content": " "}, follow_redirects=False)
+
+ db.expire_all()
+ assert db.scalars(select(Memory)).all() == []
+ assert "error=" in response.headers["location"]
+
+
+def test_emptying_a_memory_leaves_it_alone(db, client, registered, owner):
+ memory = _memory(db, owner, content="Prefers metric units.")
+
+ response = client.post(
+ f"/api/library/memories/{memory.id}", data={"content": ""}, follow_redirects=False
+ )
+
+ assert response.status_code == 400
+ db.expire_all()
+ assert db.get(Memory, memory.id).content == "Prefers metric units."
+
+
+def test_the_same_memory_twice_makes_one_record(db, client, registered, owner):
+ """The commonest failure in this store, and worse than wasted tokens: the
+ same preference saved twice makes removing it ambiguous for both."""
+ for _ in range(2):
+ client.post(
+ "/api/library/memories",
+ data={"content": "Prefers metric units."},
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ assert len(db.scalars(select(Memory)).all()) == 1
+
+
+# --- A name somebody else owns ------------------------------------------------
+def test_a_shared_skill_does_not_take_its_name_from_you(db, client, stranger, stranger_user, owner):
+ """`Skill` is unique on (owner_id, name), and `create_base` next door checks
+ ownership -- this one checks visibility. Share one "weekly-report" with a
+ team and nobody on that team can make their own, which reads as the name
+ being reserved instance-wide by whoever got there first."""
+ theirs = _skill(db, owner, name="weekly-report")
+ sharing.set_grants(db, theirs, user_ids=[stranger_user.id], group_ids=[])
+
+ stranger.post(
+ "/api/library/skills",
+ data={
+ "name": "weekly-report",
+ "description": "How I write mine.",
+ "body": "Step one.",
+ },
+ follow_redirects=False,
+ )
+
+ db.expire_all()
+ mine = db.scalars(select(Skill).where(Skill.owner_id == stranger_user.id)).all()
+ assert [s.name for s in mine] == ["weekly-report"]
diff --git a/tests/test_mentions.py b/tests/test_mentions.py
index e551b13..f4b90ee 100644
--- a/tests/test_mentions.py
+++ b/tests/test_mentions.py
@@ -20,6 +20,9 @@ from lembas.services import settings_store
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
new file mode 100644
index 0000000..70bbe58
--- /dev/null
+++ b/tests/test_migrations.py
@@ -0,0 +1,232 @@
+"""The upgrade path, which every existing install takes to reach 1.0.0.
+
+There is no Alembic here by design -- hard rule 4, additive-only, with
+`db/migrations.py:sync_schema` deriving the change from the models by diffing
+them against the live database. That makes the differ the *only* thing standing
+between an 0.8.x deployment and a broken one, and until this file it had no test
+that exercised it as a migration at all.
+
+`tests/conftest.py` runs `create_all` and *then* `sync_schema`, so the schema it
+is handed is always already current: the differ finds nothing missing, does
+nothing, and reports success. Every run of the suite proved that a no-op is a
+no-op. The two unit tests that did exist covered `_add_column_sql`'s DDL string
+and never touched a database.
+
+So these build an **old-shaped database with rows in it** and upgrade it for
+real. The shape is not invented: `OLD_TABLES` and `OLD_COLUMNS` are what
+actually arrived between `0.8.1` and this release, taken from
+`git diff 00ce04a..HEAD -- src/lembas/db/models/`. `test_the_recorded_shape_is_still_real`
+is what stops that list rotting into a test that upgrades nothing.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import inspect, text
+
+from lembas.db.base import Base
+from lembas.db.migrations import ensure_fts, sync_schema
+from lembas.db.session import get_engine
+
+# Tables that did not exist at 0.8.1. `sync_schema` has to create them.
+OLD_TABLES = ("chunks", "push_subscriptions", "usage")
+
+# Columns added to tables that already existed, and therefore already had rows.
+# These are the interesting half: a new *table* is empty by definition, but a
+# new *column* has to arrive beside data somebody cares about.
+OLD_COLUMNS = (
+ ("ssh_profiles", "resolves_here"),
+ ("chats", "parent_chat_id"),
+ ("chats", "unattended"),
+ ("reports", "unread_notified"),
+ ("groups", "limits_json"),
+)
+
+
+def _rollback_to_0_8_1(engine) -> None:
+ """Take a current database back to the shape 0.8.1 left behind.
+
+ Backwards rather than forwards because the alternative is importing two
+ versions of the models into one interpreter, which is not a thing Python
+ will do -- `lembas.db.models` is already imported and registered on one
+ `Base.metadata`. Dropping what was added produces the same *shape* the
+ differ has to repair, which is what is under test.
+ """
+ with engine.begin() as connection:
+ for table in OLD_TABLES:
+ connection.execute(text(f"DROP TABLE IF EXISTS {table}"))
+ for table, column in OLD_COLUMNS:
+ connection.execute(text(f"ALTER TABLE {table} DROP COLUMN {column}"))
+
+
+def _columns(engine, table: str) -> set[str]:
+ return {column["name"] for column in inspect(engine).get_columns(table)}
+
+
+def test_the_recorded_shape_is_still_real():
+ """The rollback above names tables and columns by hand, and a name that has
+ since been removed or renamed would make it silently upgrade nothing --
+ a test that passes because it tested an empty set.
+ """
+ tables = Base.metadata.tables
+
+ for table in OLD_TABLES:
+ assert table in tables, f"{table} is no longer a table; fix OLD_TABLES"
+ for table, column in OLD_COLUMNS:
+ assert table in tables, table
+ assert column in tables[table].c, f"{table}.{column} is gone; fix OLD_COLUMNS"
+
+
+def test_an_0_8_1_database_with_data_upgrades(db):
+ """The whole point. Rows written before the upgrade must still be there
+ afterwards, with their values, and the new columns must exist beside them.
+
+ A failure here is somebody's instance not starting after pressing Update,
+ or -- worse and quieter -- starting with a column that silently reads NULL.
+ """
+ engine = get_engine()
+
+ # Data first, while the schema still has the columns the ORM expects.
+ from lembas.db.models import Chat, Group, User
+
+ owner = User(name="Frodo", email="f@shire.test", password_hash="x") # noqa: S106
+ db.add(owner)
+ db.commit()
+ group = Group(name="Fellowship")
+ db.add(group)
+ db.commit()
+ chat = Chat(user_id=owner.id, model_id="mithril", title="A chat from before")
+ db.add(chat)
+ db.commit()
+ chat_id, owner_id, group_id = chat.id, owner.id, group.id
+ db.close()
+
+ _rollback_to_0_8_1(engine)
+
+ # Confirm the rollback actually removed things, or the assertions below
+ # would pass against a database that was never old.
+ assert "chunks" not in inspect(engine).get_table_names()
+ assert "unattended" not in _columns(engine, "chats")
+
+ changes = sync_schema(engine)
+
+ assert changes, "the differ reported nothing to do on an old database"
+ for table in OLD_TABLES:
+ assert table in inspect(engine).get_table_names(), table
+ for table, column in OLD_COLUMNS:
+ assert column in _columns(engine, table), f"{table}.{column}"
+
+ # And the rows are still what they were.
+ with engine.connect() as connection:
+ row = connection.execute(
+ text("SELECT title, model_id, user_id FROM chats WHERE id = :id"),
+ {"id": chat_id},
+ ).one()
+ assert row.title == "A chat from before"
+ assert row.model_id == "mithril"
+ assert row.user_id == owner_id
+ assert (
+ connection.execute(
+ text("SELECT name FROM groups WHERE id = :id"), {"id": group_id}
+ ).scalar()
+ == "Fellowship"
+ )
+
+
+def test_a_nullable_column_arrives_empty_rather_than_defaulted(db):
+ """A nullable column is added with **no** default, so an existing row reads
+ NULL -- the value the model treats as absent.
+
+ An earlier version defaulted every column by type, which meant an added
+ foreign key arrived as `""` on old rows, and every "is this set?" check
+ downstream was wrong about them. `parent_chat_id` is exactly that shape: a
+ chat that predates subagents is not a helper, and `""` would not say so.
+ """
+ engine = get_engine()
+
+ from lembas.db.models import Chat, User
+
+ owner = User(name="Sam", email="s@shire.test", password_hash="x") # noqa: S106
+ db.add(owner)
+ db.commit()
+ chat = Chat(user_id=owner.id, model_id="m")
+ db.add(chat)
+ db.commit()
+ chat_id = chat.id
+ db.close()
+
+ _rollback_to_0_8_1(engine)
+ sync_schema(engine)
+
+ with engine.connect() as connection:
+ parent = connection.execute(
+ text("SELECT parent_chat_id FROM chats WHERE id = :id"), {"id": chat_id}
+ ).scalar()
+ assert parent is None, "an added foreign key must be absent, not empty"
+
+
+def test_a_not_null_column_backfills_every_existing_row(db):
+ """SQLite refuses to add a NOT NULL column without a default, so one is
+ derived from the type. `chats.unattended` is a boolean: an old row has to
+ come back False rather than NULL, or every `if chat.unattended` downstream
+ reads a null as a value.
+ """
+ engine = get_engine()
+
+ from lembas.db.models import Chat, User
+
+ owner = User(name="Merry", email="m@shire.test", password_hash="x") # noqa: S106
+ db.add(owner)
+ db.commit()
+ db.add(Chat(user_id=owner.id, model_id="m"))
+ db.commit()
+ db.close()
+
+ _rollback_to_0_8_1(engine)
+ sync_schema(engine)
+
+ with engine.connect() as connection:
+ values = connection.execute(text("SELECT unattended FROM chats")).scalars().all()
+ assert values, "no rows survived the upgrade"
+ assert all(value in (0, False) for value in values), values
+
+
+def test_upgrading_twice_changes_nothing_the_second_time(db):
+ """It runs on every startup and has to converge. A second pass reporting
+ work would mean it was re-adding something, which on SQLite is an error that
+ stops the application booting."""
+ engine = get_engine()
+ _rollback_to_0_8_1(engine)
+
+ assert sync_schema(engine)
+ assert sync_schema(engine) == []
+
+
+def test_the_search_indexes_are_rebuilt_when_missing(db):
+ """FTS5 tables are not SQLAlchemy models, so `create_all` cannot make them
+ and the column differ cannot see them. `ensure_fts` is what converges them,
+ and it runs at every startup for the same reason the column sync does --
+ an instance whose indexes were dropped must repair itself rather than
+ return nothing and call it an empty library.
+ """
+ engine = get_engine()
+ with engine.begin() as connection:
+ connection.execute(text("DROP TABLE IF EXISTS documents_fts"))
+
+ assert "documents_fts" in ensure_fts(engine)
+ assert "documents_fts" in inspect(engine).get_table_names()
+ # Converges: a second call has nothing left to make.
+ assert ensure_fts(engine) == []
+
+
+@pytest.mark.parametrize("table", OLD_TABLES)
+def test_each_new_table_is_usable_after_the_upgrade(db, table):
+ """Created is not the same as correct. A table the differ made but got the
+ columns wrong on fails at the first insert, which is the first *reply* on an
+ upgraded instance rather than at startup."""
+ engine = get_engine()
+ _rollback_to_0_8_1(engine)
+ sync_schema(engine)
+
+ declared = {column.name for column in Base.metadata.tables[table].c}
+ assert _columns(engine, table) == declared
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..22283c0
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,150 @@
+"""One run against a real server on a real socket.
+
+Every other HTTP test in this suite goes through `TestClient`, which is an
+in-process ASGI call: no socket, no uvicorn, no HTTP parsing. That covers the
+application and covers nothing about the thing `deploy/install.sh` actually
+starts. Uvicorn's own behaviour -- how it frames a streaming response, whether
+it holds a connection open, what it does on shutdown with a stream still
+running -- is what a deployment depends on and what nothing here touched.
+
+Deliberately **one** test file and a handful of assertions. This is a smoke
+test: it is here so that "the server starts and serves" is a fact rather than an
+inference, not to re-test the application through a slower transport.
+
+Marked `slow` because it binds a port and waits for a process.
+"""
+
+from __future__ import annotations
+
+import socket
+import subprocess
+import sys
+import time
+
+import httpx
+import pytest
+
+pytestmark = pytest.mark.slow
+
+
+def _free_port() -> int:
+ with socket.socket() as probe:
+ probe.bind(("127.0.0.1", 0))
+ return probe.getsockname()[1]
+
+
+@pytest.fixture
+def server(tmp_path):
+ """A real `lembas serve`, on a real port, with its own data directory.
+
+ Started as a subprocess rather than a thread: the point is the process the
+ unit file starts, and an in-thread uvicorn shares this interpreter's already
+ imported settings singleton.
+ """
+ port = _free_port()
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "HOME": str(tmp_path),
+ "LEMBAS_DATA_DIR": str(tmp_path),
+ "LEMBAS_HOST": "127.0.0.1",
+ "LEMBAS_PORT": str(port),
+ "LEMBAS_SECRET_KEY": "t" * 44,
+ "LEMBAS_ALLOW_SIGNUP": "true",
+ }
+ process = subprocess.Popen(
+ [sys.executable, "-m", "lembas.cli", "serve"],
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ base = f"http://127.0.0.1:{port}"
+ try:
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ raise AssertionError(f"the server exited: {process.stdout.read()}")
+ try:
+ httpx.get(f"{base}/healthz", timeout=1.0)
+ break
+ except httpx.TransportError:
+ time.sleep(0.2)
+ else: # pragma: no cover - only on a machine that cannot start it
+ raise AssertionError("the server never answered")
+ yield base
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired: # pragma: no cover
+ process.kill()
+
+
+def test_it_starts_and_answers(server):
+ """`lembas serve` is what the systemd unit runs, and until now nothing
+ checked that the command in the unit file works at all."""
+ response = httpx.get(f"{server}/healthz", timeout=10.0)
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+
+def test_a_signed_out_visitor_is_sent_to_the_sign_in_page(server):
+ """Over real HTTP, with a real redirect, because the redirect is a header
+ and headers are the part `TestClient` does not have to get right."""
+ response = httpx.get(f"{server}/", follow_redirects=False, timeout=10.0)
+
+ assert response.status_code in (302, 303, 307)
+ assert "/auth/login" in response.headers["location"]
+
+
+def test_the_stylesheets_it_serves_are_the_ones_in_the_tree(server):
+ """The static mount, which is configuration rather than code and therefore
+ fails at deploy time rather than in a unit test."""
+ response = httpx.get(f"{server}/static/css/app.css", timeout=10.0)
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/css")
+ assert ".sidebar" in response.text
+
+
+def test_a_fresh_instance_offers_a_way_in(server):
+ """The first thing a new deployment does. On an empty database `/auth/login`
+ redirects to registration, because an instance with no accounts and a sign-in
+ form is a door with no key -- and the first account made becomes the
+ administrator.
+
+ Worth having over real HTTP: this is the exact path somebody walks thirty
+ seconds after `install.sh` finishes, and it is a chain of redirects, which is
+ the part that is headers rather than code.
+ """
+ response = httpx.get(f"{server}/auth/login", follow_redirects=True, timeout=10.0)
+
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "register" in str(response.url) or "register" in response.text.lower()
+
+
+def test_an_account_can_be_created_and_used_over_real_http(server):
+ """Registration, the session cookie, and a page that needs it -- end to end
+ through uvicorn. A cookie is `Set-Cookie` plus the browser sending it back,
+ and both halves are transport rather than application: `TestClient` has its
+ own cookie jar and would pass whatever the header said.
+ """
+ with httpx.Client(base_url=server, timeout=15.0, follow_redirects=True) as client:
+ made = client.post(
+ "/auth/register",
+ data={
+ "name": "Frodo",
+ "email": "f@shire.test",
+ "password": "speak-friend-and-enter",
+ "confirm": "speak-friend-and-enter",
+ },
+ )
+ assert made.status_code == 200, made.text[:400]
+ assert client.cookies, "no session cookie came back"
+
+ # And the cookie actually admits us to something that requires one.
+ chat = client.get("/chat")
+ assert chat.status_code == 200
+ assert "auth/login" not in str(chat.url)
diff --git a/tests/test_sse.py b/tests/test_sse.py
new file mode 100644
index 0000000..30b8a83
--- /dev/null
+++ b/tests/test_sse.py
@@ -0,0 +1,105 @@
+"""The wire format every reply travels over, which had no test of its own.
+
+`services/sse.py` is twenty lines and carries all of it. That is exactly the
+kind of module that never gets one: too small to look risky, and load-bearing
+enough that a subtle mistake is a stream which works until a model emits a
+newline -- which is to say, until the first code block.
+
+The events are parsed back the way the browser's `EventSource` does rather than
+compared against an expected string. A test asserting the literal bytes passes
+on a format that is wrong in the same way it was written.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from lembas.services.sse import KEEPALIVE, event
+
+
+def parse(frame: str) -> tuple[str, str]:
+ """Read one frame the way an `EventSource` does.
+
+ Field lines are `name: value`, the space after the colon is stripped, and
+ several `data:` lines in one event are joined with a newline. That last rule
+ is the whole reason `event()` exists.
+ """
+ name = ""
+ data: list[str] = []
+ for line in frame.split("\n"):
+ if line.startswith("event:"):
+ name = line[len("event:") :].lstrip(" ")
+ elif line.startswith("data:"):
+ data.append(line[len("data:") :].lstrip(" "))
+ return name, "\n".join(data)
+
+
+def test_a_plain_payload_survives_the_round_trip():
+ assert parse(event("render", "hello")) == ("render", "hello")
+
+
+def test_a_frame_ends_with_a_blank_line():
+ """The blank line is what tells the browser the event is over. Without it
+ nothing is dispatched at all and the stream simply appears to hang."""
+ assert event("render", "hello").endswith("\n\n")
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "line one\nline two",
+ "```python\nprint('hi')\n```",
+ "\nleading",
+ "trailing\n",
+ "two\n\nblank",
+ "markup
\nand more
",
+ ],
+)
+def test_newlines_survive_because_they_are_split_across_data_lines(payload):
+ """The failure this module exists to prevent. A raw newline inside one
+ `data:` line ends that line, so everything after it is dropped -- the event
+ arrives truncated, with no error anywhere, the first time a model writes a
+ code block."""
+ name, back = parse(event("render", payload))
+
+ assert name == "render"
+ assert back == payload
+
+
+def test_every_line_of_a_multiline_payload_is_its_own_field():
+ frame = event("steps", "a\nb\nc")
+
+ assert frame.count("data: ") == 3
+ assert "data: a\ndata: b\ndata: c\n" in frame
+
+
+def test_an_empty_payload_is_still_a_frame():
+ """Several frames must be able to blank themselves -- `metrics`, `status`,
+ `ask`, `reasoning`, `think` and `render` are sent on every version bump
+ *including* empty, because each has to be able to clear. An approval card
+ that survived being answered would be a button you could press twice."""
+ name, back = parse(event("ask", ""))
+
+ assert name == "ask"
+ assert back == ""
+ assert event("ask", "").endswith("\n\n")
+
+
+def test_a_payload_that_looks_like_a_field_is_not_read_as_one():
+ """Model output is untrusted, and `event: done` inside a reply must stay
+ text. It does because every line is prefixed -- but the reason is worth
+ pinning, since the day it is not, a model can end its own stream."""
+ hostile = "event: done\ndata: {}"
+ name, back = parse(event("render", hostile))
+
+ assert name == "render", "the payload took over the event name"
+ assert back == hostile
+
+
+def test_the_keepalive_is_a_comment_and_not_an_event():
+ """It exists to stop a proxy killing an idle stream while a model thinks.
+ A comment line does that without dispatching anything; an event would reach
+ the page and be swapped into it."""
+ assert KEEPALIVE.startswith(":")
+ assert KEEPALIVE.endswith("\n\n")
+ assert parse(KEEPALIVE) == ("", "")
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
index 16bae36..6d3cc06 100644
--- a/tests/test_terminal.py
+++ b/tests/test_terminal.py
@@ -15,6 +15,9 @@ from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError
from lembas.services.agent.terminal import Session
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_terminal_capture.py b/tests/test_terminal_capture.py
index b44dec3..a367e5b 100644
--- a/tests/test_terminal_capture.py
+++ b/tests/test_terminal_capture.py
@@ -14,6 +14,9 @@ import pytest
from lembas.services.agent import capture, shell_marks
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_terminal_js.py b/tests/test_terminal_js.py
new file mode 100644
index 0000000..271bb94
--- /dev/null
+++ b/tests/test_terminal_js.py
@@ -0,0 +1,268 @@
+"""The terminal panel, checked without a runtime.
+
+There is no JavaScript test runner here and hard rule 1 keeps Node out of the
+project, so the behaviour is driven by hand under a DOM stub before committing.
+Almost everything this file gets wrong is silent: `fit()` measures `offsetWidth`,
+which is 0 inside a `[hidden]` ancestor, so fitting while closed does nothing at
+all and leaves an 80-column shell in a 34rem panel; a window `resize` event does
+not fire when the sidebar is toggled, which is the commonest way the panel
+changes size; and a handshake against the app root fails into `onerror` and
+blames the proxy for a connection the reader simply has not chosen yet.
+
+What the suite can pin are the guards those three depend on, and the properties
+around them whose absence looks exactly like working.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import lembas
+
+from .conftest import js_code, js_function, js_says, script
+
+SOURCE = script("terminal.js")
+CODE = js_code(SOURCE)
+TEMPLATES = Path(lembas.__file__).parent / "web/templates"
+
+
+def _after(text: str, marker: str, span: int = 500) -> str:
+ """The code just after a marker -- for a handler with no name to ask for."""
+ return text[text.index(marker) :][:span]
+
+
+# --- Connecting ---------------------------------------------------------------
+def test_connect_refuses_a_panel_with_no_target():
+ """With no connection chosen `data-url` is empty, and the URL below then
+ becomes `ws://host?cols=80&rows=24` -- a WebSocket handshake against the
+ application root. That fails into `onerror`, whose message guesses at a
+ proxy not passing upgrades through: a confident, wrong explanation for
+ something the reader has simply not chosen yet.
+
+ The guard has to come before the socket, which is the whole assertion.
+ """
+ connect = js_function(SOURCE, "connect")
+
+ assert "!panel.dataset.url" in connect, "the empty-target guard is gone"
+ assert connect.index("!panel.dataset.url") < connect.index("new WebSocket"), (
+ "a panel with no target still opens a socket"
+ )
+ # And says so, rather than leaving the panel silent.
+ assert "say(" in connect[: connect.index("new WebSocket")]
+
+
+def test_the_socket_url_is_built_from_the_panel_s_own_path():
+ """The path is server-rendered onto the panel, and `draft.js` rewrites it
+ before a chat exists. Assembling one here from a chat id would be a second
+ place for it to be got subtly differently, and a wrong one connects to
+ another machine's shell rather than failing."""
+ connect = js_function(SOURCE, "connect")
+
+ assert "location.host + panel.dataset.url" in connect
+ # A page served over TLS must not open a plain socket; browsers block it,
+ # which reads as the terminal never connecting.
+ assert js_says(connect, 'location.protocol === "https:"', '"wss://"', '"ws://"')
+
+
+def test_a_deliberate_close_is_flagged_before_it_is_made():
+ """Both reconnect paths close a socket on purpose -- the server disconnecting
+ a window that stopped reading, and the composer changing which machine the
+ panel points at. The flag is what stops "Disconnected. Close and reopen to
+ reconnect." being reported for a connection nobody lost, so it has to be set
+ before the close, not after it."""
+ for marker in ('payload.t === "behind"', "repointTerminal = function"):
+ window = _after(CODE, marker)
+ assert window.index("closedOnPurpose = true") < window.index("socket.close()"), marker
+
+
+def test_a_malformed_control_frame_is_ignored():
+ """Text frames carry JSON and PTY bytes do not, and the two share one
+ `onmessage`. An exception here kills the handler for the rest of the
+ session, so the shell keeps running and the panel stops showing it."""
+ control = js_function(SOURCE, "control")
+ assert "try {" in control
+ assert control.index("JSON.parse") < control.index("catch")
+
+
+def test_bytes_go_through_to_xterm_undecoded():
+ """xterm's decoder is stateful across calls, so a multi-byte character split
+ across two frames still lands correctly -- which is exactly why the server
+ never decodes either. Decoding here would break the same character in the
+ other direction."""
+ assert js_says(CODE, "opened.binaryType", '"arraybuffer"')
+ assert js_says(CODE, "term.write(", "new Uint8Array(event.data)")
+ assert "TextDecoder" not in CODE
+
+
+# --- Sizing -------------------------------------------------------------------
+def test_a_hidden_panel_is_never_fitted():
+ """`fit()` measures `offsetWidth`, which is 0 inside a `[hidden]` ancestor.
+ Fitting while closed therefore succeeds, silently, at nothing -- and leaves
+ an 80-column terminal in a panel that is not 80 columns wide, which reads as
+ xterm being broken rather than as a call made too early."""
+ refit = js_function(SOURCE, "refit")
+ assert refit.index("visible()") < refit.index("fit.fit()")
+
+ visible = js_function(SOURCE, "visible")
+ assert 'hasAttribute("hidden")' in visible
+ assert "offsetWidth > 0" in visible, "an ancestor's `hidden` measures as zero, not as hidden"
+
+
+def test_opening_waits_a_frame_before_fitting():
+ """The panel has just had `hidden` removed and has no measured width yet, so
+ a fit in the same turn is the silent no-op above."""
+ opener = js_function(SOURCE, "open")
+ assert opener.index("requestAnimationFrame") < opener.index("refit()")
+
+
+def test_the_panel_watches_its_own_size():
+ """A window `resize` event does not fire when the sidebar is toggled or a
+ panel opens beside this one, and those are by far the commonest ways this
+ panel changes size. Without the observer the terminal keeps the width it had
+ when it was opened and wraps every line at the wrong column."""
+ build = js_function(SOURCE, "build")
+ assert "ResizeObserver" in build
+ assert "observe(panel)" in build
+
+
+def test_a_resize_is_told_to_the_far_side():
+ """Fitting changes what this end draws; the PTY on the other end keeps its
+ old window size until it is told, so a full-screen program like `vim` or
+ `htop` paints to a geometry that is no longer there."""
+ refit = js_function(SOURCE, "refit")
+ assert js_says(refit, 't: "resize"', "cols: term.cols", "rows: term.rows")
+
+
+def test_closing_the_panel_leaves_the_session_alone():
+ """`write()` is queued internally, so disposing mid-output drops it; keeping
+ the object is what makes reopening instant; and the shell on the far side
+ outlives this panel by design. Closing must therefore do nothing at all."""
+ toggle = CODE[CODE.index('"lembas:toggle"') : CODE.index('panel.addEventListener("click"')]
+ assert "open()" in toggle
+ assert "dispose" not in toggle
+ assert ".close(" not in toggle
+ assert "dispose" not in CODE, "nothing here may dispose the terminal"
+
+
+# --- The theme ----------------------------------------------------------------
+def test_the_theme_is_pushed_in_when_it_changes():
+ """xterm holds colours as values, not as variables, so it never notices a
+ `data-theme` swap. Without this, switching to `shire` leaves a black
+ rectangle in a light interface."""
+ assert '"lembas:theme"' in CODE
+ assert js_says(CODE, "term.options.theme", "readTheme()")
+
+
+def test_a_colour_slot_with_no_token_is_left_off_entirely():
+ """xterm treats `undefined` as a colour and renders it black, so a missing
+ ANSI token would not fall back -- it would paint that slot black on a light
+ theme, which is one unreadable colour rather than a visible failure."""
+ read = js_function(SOURCE, "readTheme")
+ assert js_says(read, "if (value)", "theme[name]", "value")
+
+
+# --- Handing a command to the chat --------------------------------------------
+def test_a_command_line_is_never_rendered_as_markup():
+ """Everything shown here came off somebody's machine: a command line, a
+ working directory, a message from the far side. Hard rule 6 again."""
+ assert "innerHTML" not in CODE
+ assert "textContent" in js_function(SOURCE, "say")
+ assert "textContent" in js_function(SOURCE, "showLast")
+
+
+def test_the_button_puts_it_in_the_box_rather_than_sending_it():
+ """What a machine printed is exactly the sort of text somebody should read
+ before a model does, and the box is where that happens. Only the `auto:
+ send` mode skips that, and only because it was chosen."""
+ send = js_function(SOURCE, "sendToChat")
+ assert "intoComposer" in send
+ assert "sendStraightToChat" not in send
+
+
+def test_writing_into_the_composer_repaints_its_mirror():
+ """The mirror is only repainted on `input` and after a swap, and this is
+ neither -- so without the call the highlighting sits over the previous text
+ until the next keystroke, shifted by however much was just inserted."""
+ into = js_function(SOURCE, "intoComposer")
+ assert "paintComposer" in into
+ assert "autosize" in into
+
+
+def test_the_automatic_path_never_steals_focus():
+ """It fires while somebody is typing in the terminal, and yanking the caret
+ out of a shell mid-command is the sort of thing that gets a feature switched
+ off for good."""
+ into = js_function(SOURCE, "intoComposer")
+ assert js_says(into, "!quiet", "input.focus()")
+ # The `command` frame is the automatic path, and it is the quiet one.
+ assert "capture(true)" in _after(CODE, 'payload.t === "command"')
+
+
+def test_a_screen_scrape_says_that_is_what_it_is():
+ """Without shell integration this is the last forty rows as they appeared,
+ wraps and all. Presenting that as a command and its output would put a
+ precise-looking block in front of a model that is not one."""
+ capture = js_function(SOURCE, "capture")
+ assert "scraped()" in capture
+ assert "say(" in capture[capture.index("scraped()") :]
+
+
+def test_auto_is_disabled_rather_than_degraded():
+ """Forty arbitrary lines attached to every message is worse than nothing
+ attached at all, so the control is switched off where it cannot work -- and
+ switched back off if it was already on when the markers went away."""
+ apply_ = js_function(SOURCE, "applyIntegration")
+ assert 'integration === "live"' in apply_
+ assert js_says(apply_, "auto.disabled", "!usable")
+ assert js_says(apply_, "!usable", 'autoMode !== "off"', 'setAuto("off")')
+
+
+def test_auto_is_not_remembered_between_page_loads():
+ """A switch that forwards every command you run to a model is not something
+ to inherit from last week."""
+ assert "localStorage" not in CODE
+ assert "sessionStorage" not in CODE
+
+
+# --- Where it is loaded -------------------------------------------------------
+def test_xterm_is_loaded_only_where_a_shell_can_be_opened():
+ """xterm is 280KB, more than everything else vendored here together. Hard
+ rule 1 allows it exactly once, and only on a chat that can open a terminal
+ -- an unconditional script tag would put it on every page in the
+ application."""
+ page = (TEMPLATES / "chat/index.html").read_text(encoding="utf-8")
+
+ guarded = ""
+ at = 0
+ while (at := page.find("{% if terminal_enabled %}", at)) != -1:
+ guarded += page[at : page.index("{% endif %}", at)]
+ at += 1
+
+ for asset in ("vendor/xterm.js", "vendor/xterm-addon-fit.js", "js/terminal.js"):
+ assert page.count(asset) == 1, f"{asset} is referenced more than once"
+ assert asset in guarded, f"{asset} is loaded outside the terminal_enabled guard"
+
+
+def test_a_handler_only_touches_the_connection_it_belongs_to():
+ """`close()` queues its event rather than firing it, and both reconnect
+ paths close the old socket and immediately open a new one. So the old
+ socket's `close` arrives *after* the new one is assigned -- and a handler
+ reading the module-level `socket` reached past its own connection into the
+ live one and set it to null.
+
+ Output kept arriving, because `onmessage` is bound to the object. But
+ `send`, `term.onData` and the paste handler all gate on
+ `socket && readyState === OPEN`, so every keystroke was dropped and no
+ resize was ever sent again -- on a panel that looked perfectly healthy. And
+ `closedOnPurpose` had been cleared for the new connection, so it announced
+ "Disconnected" about a shell that had just reconnected successfully.
+ """
+ code = js_code(script("terminal.js"))
+
+ assert "var opened = new WebSocket(url)" in code
+ # Every handler compares itself against the current connection first.
+ for handler in ("onmessage", "onclose", "onerror"):
+ body = code.split(f"opened.{handler} = function", 1)[1]
+ guard = body[: body.index("{", body.index(")")) + 200]
+ assert "socket !== opened" in guard, handler
+ assert "socket.onclose = function" not in code, "a handler bound to the shared variable"
diff --git a/tests/test_terminal_socket.py b/tests/test_terminal_socket.py
index c2e2fa7..5188eed 100644
--- a/tests/test_terminal_socket.py
+++ b/tests/test_terminal_socket.py
@@ -18,6 +18,9 @@ from lembas.services import settings_store
from lembas.services.agent import policy
from lembas.services.agent import terminal as terminal_service
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
+
asyncssh = pytest.importorskip("asyncssh")
diff --git a/tests/test_ui_js.py b/tests/test_ui_js.py
index 3a4081d..ef2c1c5 100644
--- a/tests/test_ui_js.py
+++ b/tests/test_ui_js.py
@@ -298,3 +298,33 @@ def test_turning_notifications_off_also_drops_the_registration():
service for something the reader has switched off."""
assert "/api/push/unsubscribe" in SOURCE
assert "pushManager" in SOURCE
+
+
+def test_no_page_loads_a_script_that_the_base_template_already_loads():
+ """`/messages` listed `composer.js` and `commands.js` in its `scripts`
+ block, and `base.html` already loads both on every page -- so that screen
+ ran each of them twice.
+
+ Each is an IIFE with its own state, and `stopPropagation()` does not stop a
+ second listener already bound to the same node. Two composer menus stacked
+ on each other; one Enter on a highlighted `/` item ran the command twice, so
+ `/help` opened two dialogs and `/image` posted the message twice; an `@`
+ mention attached its file twice; and `Alt+B`, `Alt+E`, `Alt+T` and `Alt+I`
+ each toggled their panel twice, which is to say did nothing at all.
+
+ None of that looks like a script loaded twice, which is why this is a sweep
+ over every template rather than a note about one of them.
+ """
+ import re
+ from pathlib import Path
+
+ templates = Path(__file__).resolve().parents[1] / "src/lembas/web/templates"
+ pattern = re.compile(r"path='js/([a-z_]+\.js)'")
+ always = set(pattern.findall((templates / "base.html").read_text()))
+ assert always, "base.html stopped loading any script; this test is now blind"
+
+ for template in templates.rglob("*.html"):
+ if template.name == "base.html":
+ continue
+ again = set(pattern.findall(template.read_text())) & always
+ assert not again, f"{template.name} re-loads {sorted(again)}, which base.html has"
diff --git a/tests/test_updates.py b/tests/test_updates.py
index ada2057..e5b1863 100644
--- a/tests/test_updates.py
+++ b/tests/test_updates.py
@@ -13,6 +13,8 @@ import pytest
from lembas.config import settings
from lembas.services import updates
+# Stands up something real -- see the `slow` marker in pyproject.toml.
+pytestmark = pytest.mark.slow
@pytest.fixture
def tagged():