Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 18s
Android / Core and FFI clippy and tests (push) Successful in 45s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 18s
Android / Core and FFI clippy and tests (push) Successful in 45s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118): Practice 3, CI fails on the wrong signer. The signing step printed the certificate and went on. It now fails unless the APK has exactly one signer and that signer is the release certificate (SHA-256 408a5835…, pinned from run 8753). The steps that publish come after it in the same job, so a wrongly signed build is never staged or published. Practice 6, app downloads are throttled and carry a sha256 ETag. - The download route counts per account and answers 429 with Retry-After past the limit. The limit is a new Settings → Security value, "App downloads per account per hour" (default 30), live like the sign-in limits. - The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a phone resuming a download across a redeploy is not told its partial copy is stale. Quart's own ETag and conditional handling are off, and the route runs the conditional pass after setting the ETag, so Range and If-Range are judged against the content. Practice 9, the update offer and debug builds. - The install-permission notice re-reads the grant each time the app comes back, as ReminderNotice does. Read once, it stayed up after someone granted the permission in Settings and came back. - A debuggable build says it can't update itself and checks for nothing. Android would refuse the release-signed APK over a debug signature anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -422,3 +422,64 @@ async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
|
||||
"up to date", so the 404 has to be there to turn."""
|
||||
resp = await app.test_client().get("/api/client/linux-appimage/update.json")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- serving the bytes (#5118, family idea #5103 practice 6) -----------------
|
||||
#
|
||||
# `send_artifact` is called directly in a request context: the route in front of it
|
||||
# needs a signed-in account, and this suite has no database to sign one in with.
|
||||
|
||||
|
||||
async def _send(app, headers: dict):
|
||||
place("android")
|
||||
found = release("android")
|
||||
root = Path(Config.client_root())
|
||||
async with app.test_request_context("/api/client/android/download", headers=headers):
|
||||
resp = await client_dist.send_artifact(root, BY_ID["android"], found)
|
||||
return resp, await resp.get_data()
|
||||
|
||||
|
||||
async def test_the_etag_is_the_sha256_the_sidecar_records(app):
|
||||
"""Content identity, not Quart's mtime-and-path: the same bytes after a redeploy
|
||||
must still match a partial download a phone is resuming."""
|
||||
resp, body = await _send(app, {})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["ETag"] == f'"{"ab" * 32}"'
|
||||
assert body == PAYLOAD
|
||||
|
||||
|
||||
async def test_a_range_request_gets_just_that_range(app):
|
||||
resp, body = await _send(app, {"Range": "bytes=4-9"})
|
||||
assert resp.status_code == 206
|
||||
assert body == PAYLOAD[4:10]
|
||||
assert resp.headers["Content-Range"] == f"bytes 4-9/{len(PAYLOAD)}"
|
||||
assert resp.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
|
||||
async def test_resuming_a_different_build_starts_again_from_the_top(app):
|
||||
"""If-Range names the build the partial copy came from. A different one must
|
||||
not be stitched onto it: the whole file comes back instead."""
|
||||
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"cd" * 32}"'})
|
||||
assert resp.status_code == 200
|
||||
assert body == PAYLOAD
|
||||
|
||||
|
||||
async def test_resuming_the_same_build_continues_it(app):
|
||||
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"ab" * 32}"'})
|
||||
assert resp.status_code == 206
|
||||
assert body == PAYLOAD[4:10]
|
||||
|
||||
|
||||
async def test_a_client_holding_this_build_is_told_nothing_changed(app):
|
||||
resp, body = await _send(app, {"If-None-Match": f'"{"ab" * 32}"'})
|
||||
assert resp.status_code == 304
|
||||
assert body == b""
|
||||
|
||||
|
||||
def test_the_download_throttle_reads_its_limit_from_settings():
|
||||
"""The limit an admin saves is the one that applies, without a restart."""
|
||||
from inkwell import ratelimit
|
||||
from inkwell.settings import live
|
||||
|
||||
assert ratelimit.downloads_by_account.limit == live("client_downloads_per_hour")
|
||||
assert ratelimit.downloads_by_account.window_s == 3600.0
|
||||
|
||||
@@ -480,6 +480,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
"reset_emails_per_account",
|
||||
"client_downloads_per_hour",
|
||||
}
|
||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||
# was never told about.
|
||||
|
||||
Reference in New Issue
Block a user