CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253). curl -fsSL https://notes.example.com/install.sh | sh The server serves the installer at /install.sh with its own address written into it (installer.py). Settings → Public address when set, the request's own address otherwise. The substitution is one variable, given a value that has passed a strict shape check, and the script checks it again; an address that cannot pass makes the route refuse rather than serve a script pointed elsewhere. `public_url` joins the live settings cache so the route needs no database. From a server, the script: - resolves each Linux bundle from the public /api/client/<platform>, and builds the download URL from the platform id rather than reading it from the reply; - asks for a device token (from the terminal, since stdin is the script), or takes TS_TOKEN, and sends it from a file rather than the command line; - checks the sha256 the server published before anything installs; - revokes a prompted token once the download is done; - records `install-server` for the updater (step 6) instead of the channel. The forge path is unchanged, and stays the default for the copy the forge serves. The Account page's downloads card shows the one-line command whenever the server holds a Linux client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
4.7 KiB
Python
124 lines
4.7 KiB
Python
import pytest
|
|
|
|
from inkwell import installer, settings
|
|
from inkwell.app import create_app
|
|
from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address
|
|
|
|
# DB-free, like test_client_dist: the route reads `public_url` from the live cache
|
|
# rather than from the database, which is what makes it testable here.
|
|
|
|
SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _defaults():
|
|
settings.reset_live()
|
|
yield
|
|
settings.reset_live()
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
return create_app()
|
|
|
|
|
|
def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once():
|
|
"""The forge-served script and the server-served one are the same file; this is
|
|
the one line that differs. Edit it and the server stops serving rather than
|
|
serving a script with no address in it — this test is what says so first."""
|
|
assert SOURCE_SCRIPT.read_text(encoding="utf-8").count(PLACEHOLDER) == 1
|
|
|
|
|
|
def test_render_writes_the_address_into_the_one_variable():
|
|
body = render(SCRIPT, "https://notes.example.com")
|
|
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body
|
|
assert PLACEHOLDER not in body
|
|
# Nothing else moved.
|
|
assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"address",
|
|
[
|
|
"https://notes.example.com/sub/path",
|
|
"http://192.168.1.20:5000",
|
|
"https://notes.example.com:8443",
|
|
],
|
|
)
|
|
def test_an_ordinary_address_is_accepted(address):
|
|
assert render(SCRIPT, address) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"address",
|
|
[
|
|
# Each is a way out of the single quotes, or a second command.
|
|
"https://x.example.com'; rm -rf ~; '",
|
|
"https://x.example.com/$(id)",
|
|
"https://x.example.com/`id`",
|
|
"https://x.example.com\nrm -rf ~",
|
|
"https://x.example.com/a b",
|
|
"https://x.example.com/;id",
|
|
# Not an address at all.
|
|
"ftp://x.example.com",
|
|
"x.example.com",
|
|
"",
|
|
],
|
|
)
|
|
def test_an_address_that_could_mean_something_to_a_shell_is_refused(address):
|
|
assert render(SCRIPT, address) is None
|
|
|
|
|
|
@pytest.mark.parametrize("script", ["#!/bin/sh\n", SCRIPT + PLACEHOLDER + "\n"])
|
|
def test_a_script_without_exactly_one_placeholder_is_not_served(script):
|
|
assert render(script, "https://notes.example.com") is None
|
|
|
|
|
|
def test_the_public_address_setting_beats_the_request_host():
|
|
"""Behind a reverse proxy the Host this request arrived with may be an internal
|
|
one; Settings → Public address is what the operator says people use."""
|
|
assert server_address("https://notes.example.com", "http", "inkwell:5000") == "https://notes.example.com"
|
|
|
|
|
|
def test_without_the_setting_the_request_address_is_used():
|
|
assert server_address("", "https", "notes.example.com") == "https://notes.example.com"
|
|
|
|
|
|
def test_a_trailing_slash_is_dropped():
|
|
assert server_address("https://notes.example.com/", "http", "ignored") == "https://notes.example.com"
|
|
|
|
|
|
def test_a_host_header_that_is_not_an_address_yields_none():
|
|
assert server_address("", "http", "evil.example.com'$(id)") is None
|
|
|
|
|
|
async def test_the_route_serves_the_script_pointed_at_this_server(app):
|
|
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
|
|
assert resp.status_code == 200
|
|
assert resp.mimetype == "text/plain"
|
|
body = await resp.get_data(as_text=True)
|
|
assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body
|
|
assert body.startswith("#!/bin/sh")
|
|
|
|
|
|
async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch):
|
|
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com")
|
|
resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"})
|
|
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True)
|
|
|
|
|
|
async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch):
|
|
# A Public address saved before it was validated this strictly: Settings only
|
|
# checks the scheme, so this is reachable.
|
|
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'")
|
|
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
|
|
assert resp.status_code == 500
|
|
assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True)
|
|
|
|
|
|
async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(installer, "BAKED_SCRIPT", tmp_path / "absent")
|
|
monkeypatch.setattr(installer, "SOURCE_SCRIPT", tmp_path / "also-absent")
|
|
resp = await app.test_client().get("/install.sh")
|
|
assert resp.status_code == 404
|