0f44e8d24c
LLeMbas now runs end to end. Register, add an OpenAI-compatible connection, and hold a real streaming conversation organised into folders. Verified against the local llama-swap instance. Streaming is the one genuinely tricky part. Sending a message returns two HTML fragments -- the user bubble and an empty assistant bubble carrying an sse-connect -- and that attribute is the ONLY thing that starts a generation. Rendering an incomplete assistant message as a streaming shell falls out of the same template, which means loading a page whose last reply never finished simply picks it up again. Details worth knowing about, each commented where it matters: - SSE payloads are split across several data: lines. A raw newline in one data: line truncates the event, which shows up the first time a model emits a code block. - Markdown is rendered server-side by the same helper for both the page and the final streamed frame, so the two cannot disagree. The fence renderer is replaced outright rather than using markdown-it's highlight option, which re-wraps output in a second <pre>. - escape_text is html.escape, not nh3.clean_text: it escapes character by character, so escaping stream chunks separately equals escaping the whole string. - The stream opens its own session via session_scope(); it outlives the request handler and the dependency-scoped session may be closed. - Deleting a folder keeps the chats inside it (FK is SET NULL). Losing a conversation to a mis-clicked folder delete is unforgivable. - Login failures use one message for "no such account" and "wrong password" so the form cannot enumerate registered addresses. Also adds deploy/ for the gamebox install at https://chat.lan: system unit, nginx vhost with buffering off (buffering on turns streaming into one lump at the end), and install/update scripts following the same service-user and /srv bind-mount conventions as llama-swap and comfyui. 70 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
1.9 KiB
Python
68 lines
1.9 KiB
Python
"""Secret encryption and password hashing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from lembas.security.passwords import (
|
|
hash_password,
|
|
validate_password,
|
|
verify_password,
|
|
)
|
|
from lembas.services.crypto import decrypt, encrypt, mask
|
|
|
|
|
|
def test_encrypt_round_trip():
|
|
secret = "sk-proj-abcdef1234567890"
|
|
assert decrypt(encrypt(secret)) == secret
|
|
|
|
|
|
def test_encrypt_is_not_reversible_by_eye():
|
|
secret = "sk-proj-abcdef1234567890"
|
|
assert secret not in encrypt(secret)
|
|
|
|
|
|
def test_encrypt_is_randomised():
|
|
"""Fernet includes a random IV, so the same input must not repeat."""
|
|
assert encrypt("same") != encrypt("same")
|
|
|
|
|
|
def test_empty_secret_stays_empty():
|
|
"""Endpoints that need no key store nothing, not an encrypted blank."""
|
|
assert encrypt("") == ""
|
|
assert decrypt("") == ""
|
|
|
|
|
|
def test_decrypt_fails_soft_on_garbage():
|
|
"""An unreadable value means the secret key changed. The admin UI has to
|
|
stay usable so the key can simply be re-entered."""
|
|
assert decrypt("not-a-valid-token") == ""
|
|
|
|
|
|
def test_mask_keeps_enough_to_identify_but_not_to_use():
|
|
masked = mask("sk-proj-abcdef1234567890")
|
|
assert masked.startswith("sk-")
|
|
assert masked.endswith("7890")
|
|
assert "abcdef" not in masked
|
|
|
|
|
|
def test_mask_hides_short_secrets_entirely():
|
|
assert set(mask("short")) == {"*"}
|
|
|
|
|
|
def test_password_round_trip():
|
|
stored = hash_password("speak-friend")
|
|
assert verify_password("speak-friend", stored)
|
|
assert not verify_password("Speak-Friend", stored)
|
|
|
|
|
|
def test_password_hash_is_salted():
|
|
assert hash_password("same") != hash_password("same")
|
|
|
|
|
|
def test_verify_rejects_a_corrupt_hash_instead_of_raising():
|
|
assert not verify_password("anything", "not-a-hash")
|
|
|
|
|
|
def test_short_passwords_are_rejected():
|
|
assert validate_password("short") is not None
|
|
assert validate_password("long-enough-password") is None
|