From b24a057591a21d047ff569305a7e23e0fa36e0b0 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 19:18:54 +0200 Subject: [PATCH 1/4] Add SKU-aware OTA release artifacts (#56) * feat: add SKU-aware OTA release artifacts Persist OTA artifact URL/hash data separately from rollout state so stable release responses can choose artifacts by compatible SKU while release rollout remains version/type based. * fix: select compatible OTA releases by SKU Ensure stable release selection only considers releases with artifacts compatible with the requested SKU, and tighten tests around the DB-backed OTA contract. * fix: match production OTA release responses Only expose stable signature URLs that actually exist and preserve production's version-first SKU error behavior. * fix: restrict legacy OTA artifacts and make sync create-only Pre-SKU artifacts (no skus/ folder) are jetkvm-v2 only. Marking them compatible with jetkvm-v2-sdmmc would brick devices that received firmware predating their hardware. Future SKUs must opt in via an explicit skus// upload. sync-releases now skips releases already in the DB instead of upserting them. This prevents routine sync runs from rewriting Release.url/hash or appending duplicate ReleaseArtifact rows if R2_CDN_URL ever changes. Backfills and repairs are left to one-off scripts. * refactor: drop forceUpdate query parameter from /releases The flag is no longer sent by any client. Routine update checks now always go through the rollout-aware default-and-latest path, which is what forceUpdate effectively short-circuited to. Removes one query parameter, one branch in the handler, and the corresponding axis from the compare-releases sweep. * fix: skip incompatible defaults and parallelize stable DB lookups getDefaultRelease previously picked the newest 100%-rolled-out release without checking SKU compatibility. If that release lacked a compatible artifact, the request 404'd downstream even though older 100%-rolled-out releases had valid binaries for the SKU. It now filters to releases that actually ship a compatible artifact before selecting the latest, falling back to a 404 only when no compatible default exists. The four DB lookups in the stable rollout-aware path are independent; run them concurrently so background-check latency drops from ~4 round trips to ~1. --- package.json | 1 + .../migration.sql | 28 + prisma/schema.prisma | 22 +- scripts/compare-releases.sh | 758 ++++++++++++ scripts/seed.ts | 36 +- scripts/sync-releases.ts | 276 +++++ src/releases.ts | 294 +++-- test/releases.test.ts | 1023 +++++++---------- test/setup.ts | 67 +- test/sync-releases.test.ts | 177 +++ vitest.config.ts | 3 +- 11 files changed, 1987 insertions(+), 698 deletions(-) create mode 100644 prisma/migrations/20260427143200_add_release_artifacts/migration.sql create mode 100755 scripts/compare-releases.sh create mode 100644 scripts/sync-releases.ts create mode 100644 test/sync-releases.test.ts diff --git a/package.json b/package.json index 108dce3..a8648af 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "prisma-dev-migrate": "prisma migrate dev", "prisma-migrate": "prisma migrate deploy", "seed": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/seed.ts", + "sync-releases": "NODE_ENV=development node -r ts-node/register --env-file=.env.development ./scripts/sync-releases.ts", "build": "tsc", "test": "vitest run", "test:watch": "vitest", diff --git a/prisma/migrations/20260427143200_add_release_artifacts/migration.sql b/prisma/migrations/20260427143200_add_release_artifacts/migration.sql new file mode 100644 index 0000000..effb210 --- /dev/null +++ b/prisma/migrations/20260427143200_add_release_artifacts/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "ReleaseArtifact" ( + "id" BIGSERIAL NOT NULL, + "releaseId" BIGINT NOT NULL, + "url" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "compatibleSkus" TEXT[] NOT NULL, + + CONSTRAINT "ReleaseArtifact_pkey" PRIMARY KEY ("id") +); + +-- Backfill one artifact for every existing release. +-- Pre-SKU artifacts only target the original jetkvm-v2 hardware; future SKUs +-- (e.g. jetkvm-v2-sdmmc) require explicit SKU-folder uploads to be registered +-- by scripts/sync-releases.ts. +INSERT INTO "ReleaseArtifact" ("releaseId", "url", "hash", "compatibleSkus") +SELECT + "id", + "url", + "hash", + ARRAY['jetkvm-v2']::TEXT[] +FROM "Release"; + +-- CreateIndex +CREATE UNIQUE INDEX "ReleaseArtifact_releaseId_url_key" ON "ReleaseArtifact"("releaseId", "url"); + +-- AddForeignKey +ALTER TABLE "ReleaseArtifact" ADD CONSTRAINT "ReleaseArtifact_releaseId_fkey" FOREIGN KEY ("releaseId") REFERENCES "Release"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3ea1865..080a46a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,14 +43,26 @@ model TurnActivity { } model Release { - id BigInt @id @default(autoincrement()) + id BigInt @id @default(autoincrement()) version String - rolloutPercentage Int @default(10) // 10% of users - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + rolloutPercentage Int @default(10) // 10% of users + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt url String - type String @default("app") // "app" or "system" + type String @default("app") // "app" or "system" hash String + artifacts ReleaseArtifact[] @@unique([version, type]) } + +model ReleaseArtifact { + id BigInt @id @default(autoincrement()) + release Release @relation(fields: [releaseId], references: [id], onDelete: Cascade) + releaseId BigInt + url String + hash String + compatibleSkus String[] + + @@unique([releaseId, url]) +} diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh new file mode 100755 index 0000000..bb2b95c --- /dev/null +++ b/scripts/compare-releases.sh @@ -0,0 +1,758 @@ +#!/usr/bin/env bash + +set -uo pipefail + +LOCAL_BASE="${LOCAL_BASE:-http://localhost:3000}" +PROD_BASE="${PROD_BASE:-https://api.jetkvm.com}" + +DEFAULT_DEVICE_IDS=("compare-device-1") +DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc") +TRISTATE_VALUES=("__omit__" "false" "true") + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +PASS_COUNT=0 +FAIL_COUNT=0 +ACCEPTED_COUNT=0 +CASE_COUNT=0 +CASE_INDEX=0 +TOTAL_CASES=0 +PROGRESS_WIDTH=40 + +print_usage() { + cat <<'EOF' +Usage: scripts/compare-releases.sh [device_id ...] + +Compares release endpoint responses between: + - local API + - api.jetkvm.com + +Defaults: + LOCAL_BASE=http://localhost:3000 + PROD_BASE=https://api.jetkvm.com + device_ids=(compare-device-1) + +Environment overrides: + LOCAL_BASE Override local host + PROD_BASE Override production host + CURL_TIMEOUT Curl max time in seconds (default: 30) + CURL_CONNECT_TIMEOUT Curl connect timeout in seconds (default: 10) + FAIL_FAST Stop after first failed case (default: true) + +Examples: + scripts/compare-releases.sh + scripts/compare-releases.sh device-a device-b + LOCAL_BASE=http://localhost:3001 PROD_BASE=https://api.jetkvm.com scripts/compare-releases.sh +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + print_usage + exit 0 +fi + +if (($# > 0)); then + DEVICE_IDS=("$@") +else + DEVICE_IDS=("${DEFAULT_DEVICE_IDS[@]}") +fi + +CURL_TIMEOUT="${CURL_TIMEOUT:-30}" +CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-10}" +MAX_PARALLEL="${MAX_PARALLEL:-5}" +RETRY_COUNT="${RETRY_COUNT:-2}" +RETRY_DELAY_SECONDS="${RETRY_DELAY_SECONDS:-1}" +FAIL_FAST="${FAIL_FAST:-true}" + +log() { + printf '%s\n' "$*" +} + +render_progress() { + local completed="$1" + local total="$2" + local width="${3:-$PROGRESS_WIDTH}" + local filled=0 + local empty=0 + + if (( total > 0 )); then + filled=$(( completed * width / total )) + fi + empty=$(( width - filled )) + + printf '%*s' "$filled" '' | tr ' ' '#' + printf '%*s' "$empty" '' +} + +urlencode() { + python3 - "$1" <<'PY' +import sys +from urllib.parse import quote + +print(quote(sys.argv[1], safe="")) +PY +} + +join_query() { + local -n query_keys_ref=$1 + local -n query_values_ref=$2 + local query="" + local i key value encoded + + for i in "${!query_keys_ref[@]}"; do + key="${query_keys_ref[$i]}" + value="${query_values_ref[$i]}" + [[ "$value" == "__omit__" ]] && continue + encoded="$(urlencode "$value")" + if [[ -n "$query" ]]; then + query+="&" + fi + query+="${key}=${encoded}" + done + + printf '%s' "$query" +} + +header_value() { + local file="$1" + local name="$2" + python3 - "$file" "$name" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +name = sys.argv[2].lower() +value = "" +for raw_line in path.read_text(errors="replace").splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + key, candidate = line.split(":", 1) + if key.lower() == name: + value = candidate.strip() +print(value) +PY +} + +normalize_body() { + local body_file="$1" + local normalized_file="$2" + if [[ ! -f "$body_file" ]]; then + : >"$normalized_file" + return + fi + python3 - "$body_file" "$normalized_file" <<'PY' +import json +import sys +from pathlib import Path + +body_path = Path(sys.argv[1]) +normalized_path = Path(sys.argv[2]) +body = body_path.read_text(errors="replace") + +try: + parsed = json.loads(body) +except Exception: + normalized_path.write_text(body) +else: + def scrub(value): + if isinstance(value, dict): + return { + key: scrub(child) + for key, child in value.items() + if not key.endswith("CachedAt") + } + if isinstance(value, list): + return [scrub(item) for item in value] + return value + + normalized_path.write_text(json.dumps(scrub(parsed), indent=2, sort_keys=True) + "\n") +PY +} + +summarize_body_mismatch() { + local left_file="$1" + local right_file="$2" + python3 - "$left_file" "$right_file" <<'PY' +import json +import sys +from pathlib import Path + +left_path = Path(sys.argv[1]) +right_path = Path(sys.argv[2]) + +def load(path): + try: + return json.loads(path.read_text(errors="replace")) + except Exception: + return path.read_text(errors="replace") + +left = load(left_path) +right = load(right_path) + +def walk(a, b, path="$"): + if type(a) != type(b): + return path, a, b + if isinstance(a, dict): + keys = sorted(set(a) | set(b)) + for key in keys: + if key not in a: + return f"{path}.{key}", "", b[key] + if key not in b: + return f"{path}.{key}", a[key], "" + result = walk(a[key], b[key], f"{path}.{key}") + if result is not None: + return result + return None + if isinstance(a, list): + if len(a) != len(b): + return f"{path}.length", len(a), len(b) + for idx, (av, bv) in enumerate(zip(a, b)): + result = walk(av, bv, f"{path}[{idx}]") + if result is not None: + return result + return None + if a != b: + return path, a, b + return None + +result = walk(left, right) +if result is None: + print("values differ") +else: + path, left_value, right_value = result + print(f"path={path}") + print(f"local={json.dumps(left_value, sort_keys=True)}") + print(f"prod={json.dumps(right_value, sort_keys=True)}") +PY +} + +body_diff_is_version_only_not_found() { + local left_file="$1" + local right_file="$2" + python3 - "$left_file" "$right_file" <<'PY' +import json +import re +import sys +from pathlib import Path + +try: + left = json.loads(Path(sys.argv[1]).read_text(errors="replace")) + right = json.loads(Path(sys.argv[2]).read_text(errors="replace")) +except Exception: + raise SystemExit(1) + +if not (isinstance(left, dict) and isinstance(right, dict)): + raise SystemExit(1) + +if left.get("name") != "NotFoundError" or right.get("name") != "NotFoundError": + raise SystemExit(1) + +left_keys = set(left.keys()) +right_keys = set(right.keys()) +if left_keys != {"name", "message"} or right_keys != {"name", "message"}: + raise SystemExit(1) + +version_pattern = re.compile( + r'^(Version )(.+?)( predates SKU support and cannot serve SKU "[^"]+")$' +) + +left_message = left.get("message", "") +right_message = right.get("message", "") + +left_normalized = version_pattern.sub(r"\1\3", left_message) +right_normalized = version_pattern.sub(r"\1\3", right_message) + +if left_normalized == right_normalized: + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +is_accepted_deviation() { + local query="$1" + local left_prefix="$2" + local right_prefix="$3" + python3 - "$query" "${left_prefix}.meta" "${right_prefix}.meta" "${left_prefix}.normalized" "${right_prefix}.normalized" <<'PY' +import json +import sys +from pathlib import Path +from urllib.parse import parse_qs + +query, left_meta_path, right_meta_path, left_body_path, right_body_path = sys.argv[1:] +params = parse_qs(query, keep_blank_values=True) + +def one(name): + values = params.get(name, []) + return values[0] if values else None + +def parse_meta(path): + data = {} + for line in Path(path).read_text(errors="replace").splitlines(): + if "=" in line: + key, value = line.split("=", 1) + data[key] = value + return data + +def load_json(path): + try: + return json.loads(Path(path).read_text(errors="replace")) + except Exception: + return None + +left_meta = parse_meta(left_meta_path) +right_meta = parse_meta(right_meta_path) +left_body = load_json(left_body_path) +right_body = load_json(right_body_path) + +# Accepted behavior change: +# Stable requests with prerelease/dev version constraints are DB-only locally. +# Production still resolves those directly from S3. Local 404 vs prod 200 is expected. +if one("prerelease") not in (None, "false"): + raise SystemExit(1) + +constrained_versions = [one("appVersion"), one("systemVersion")] +has_dev_constraint = any(value and "-" in value for value in constrained_versions) +if not has_dev_constraint: + raise SystemExit(1) + +if left_meta.get("http_code") != "404" or right_meta.get("http_code") != "200": + raise SystemExit(1) + +if not isinstance(left_body, dict) or left_body.get("name") != "NotFoundError": + raise SystemExit(1) + +if not isinstance(right_body, dict) or not right_body.get("appVersion") or not right_body.get("systemVersion"): + raise SystemExit(1) + +raise SystemExit(0) +PY +} + +curl_capture() { + local base_url="$1" + local path="$2" + local query="$3" + local prefix="$4" + local url="${base_url}${path}" + local headers_file="${prefix}.headers" + local body_file="${prefix}.body" + local meta_file="${prefix}.meta" + local stderr_file="${prefix}.stderr" + local exit_file="${prefix}.exit" + local attempt=0 + local curl_exit=0 + local http_code="" + + if [[ -n "$query" ]]; then + url="${url}?${query}" + fi + + while :; do + : >"$headers_file" + : >"$body_file" + : >"$meta_file" + : >"$stderr_file" + + curl_exit=0 + curl \ + --silent \ + --show-error \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_TIMEOUT" \ + --dump-header "$headers_file" \ + --output "$body_file" \ + --write-out "http_code=%{http_code}\ncontent_type=%{content_type}\n" \ + "$url" >"$meta_file" 2>"$stderr_file" || curl_exit=$? + + printf '%s\n' "$curl_exit" >"$exit_file" + http_code="$(sed -n 's/^http_code=//p' "$meta_file")" + + if (( curl_exit == 0 )) && [[ ! "$http_code" =~ ^52[0-9]$ ]]; then + break + fi + + if (( attempt >= RETRY_COUNT )); then + break + fi + + attempt=$((attempt + 1)) + sleep "$RETRY_DELAY_SECONDS" + done +} + +compare_scalar_files() { + local label="$1" + local left_file="$2" + local right_file="$3" + local left_value right_value + + left_value="$(tr -d '\r' <"$left_file")" + right_value="$(tr -d '\r' <"$right_file")" + if [[ "$left_value" == "$right_value" ]]; then + return 1 + fi + + printf '%s\n' "$label" + printf ' local=%s\n' "${left_value:-}" + printf ' prod=%s\n' "${right_value:-}" + return 0 +} + +summarize_meta_mismatch() { + local left_file="$1" + local right_file="$2" + python3 - "$left_file" "$right_file" <<'PY' +import sys +from pathlib import Path + +def parse(path_str): + data = {} + for line in Path(path_str).read_text(errors="replace").splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + data[key] = value + return data + +left = parse(sys.argv[1]) +right = parse(sys.argv[2]) +keys = sorted(set(left) | set(right)) +for key in keys: + if left.get(key) != right.get(key): + print(f"{key}") + print(f"local={left.get(key, '')}") + print(f"prod={right.get(key, '')}") +PY +} + +write_case_result() { + local result_file="$1" + local case_name="$2" + local path="$3" + local query="$4" + local left_prefix="$5" + local right_prefix="$6" + local left_norm="${left_prefix}.normalized" + local right_norm="${right_prefix}.normalized" + local left_location right_location + local failed=0 + local details="" + local mismatch_count=0 + local output="" + local accepted_reason="" + + normalize_body "${left_prefix}.body" "$left_norm" + normalize_body "${right_prefix}.body" "$right_norm" + + if output="$(compare_scalar_files "exit-code mismatch" "${left_prefix}.exit" "${right_prefix}.exit")"; then + mismatch_count=$((mismatch_count + 1)) + details+="$output"$'\n' + failed=1 + fi + + if output="$(summarize_meta_mismatch "${left_prefix}.meta" "${right_prefix}.meta")" && [[ -n "$output" ]]; then + mismatch_count=$((mismatch_count + 1)) + details+=$' response-meta mismatch\n' + details+="$(printf '%s\n' "$output" | sed 's/^/ /')"$'\n' + failed=1 + fi + + left_location="$(header_value "${left_prefix}.headers" "location")" + right_location="$(header_value "${right_prefix}.headers" "location")" + if [[ "$left_location" != "$right_location" ]]; then + mismatch_count=$((mismatch_count + 1)) + details+=$' location mismatch\n' + details+=" local=${left_location:-}"$'\n' + details+=" prod=${right_location:-}"$'\n' + failed=1 + fi + + if [[ -s "${left_prefix}.body" || -s "${right_prefix}.body" ]]; then + if ! cmp -s "$left_norm" "$right_norm"; then + if body_diff_is_version_only_not_found "$left_norm" "$right_norm"; then + : + else + mismatch_count=$((mismatch_count + 1)) + details+=$' body mismatch\n' + details+="$(summarize_body_mismatch "$left_norm" "$right_norm" | sed 's/^/ /')"$'\n' + failed=1 + fi + fi + fi + + if (( failed == 1 )) && is_accepted_deviation "$query" "$left_prefix" "$right_prefix"; then + failed=0 + accepted_reason="stable dev/prerelease version constraints are DB-only locally" + details="" + mismatch_count=0 + fi + + { + printf 'status=%s\n' "$([[ $failed -eq 0 ]] && { [[ -n "$accepted_reason" ]] && printf accepted || printf pass; } || printf fail)" + printf 'case_name=%s\n' "$case_name" + printf 'path=%s\n' "$path" + printf 'query=%s\n' "$query" + printf 'accepted_reason=%s\n' "$accepted_reason" + printf 'mismatch_count=%s\n' "$mismatch_count" + printf 'details<<__DETAILS__\n%s__DETAILS__\n' "$details" + printf 'local_stderr<<__STDERR__\n%s__STDERR__\n' "$(tr '\n' ' ' <"${left_prefix}.stderr")" + printf 'prod_stderr<<__STDERR__\n%s__STDERR__\n' "$(tr '\n' ' ' <"${right_prefix}.stderr")" + } >"$result_file" +} + +run_case_worker() { + local case_name="$1" + local path="$2" + local query="$3" + local safe_case="$4" + local result_file="$5" + + local local_prefix="$TMP_DIR/${safe_case}.local" + local prod_prefix="$TMP_DIR/${safe_case}.prod" + + curl_capture "$LOCAL_BASE" "$path" "$query" "$local_prefix" & + local local_pid=$! + curl_capture "$PROD_BASE" "$path" "$query" "$prod_prefix" & + local prod_pid=$! + wait "$local_pid" + wait "$prod_pid" + + write_case_result "$result_file" "$case_name" "$path" "$query" "$local_prefix" "$prod_prefix" +} + +print_case_result() { + local result_file="$1" + local progress_bar + local status case_name path query accepted_reason mismatch_count details local_stderr prod_stderr + + progress_bar="$(render_progress "$CASE_INDEX" "$TOTAL_CASES")" + status="$(sed -n 's/^status=//p' "$result_file")" + case_name="$(sed -n 's/^case_name=//p' "$result_file")" + path="$(sed -n 's/^path=//p' "$result_file")" + query="$(sed -n 's/^query=//p' "$result_file")" + accepted_reason="$(sed -n 's/^accepted_reason=//p' "$result_file")" + mismatch_count="$(sed -n 's/^mismatch_count=//p' "$result_file")" + details="$(awk '/^details<<__DETAILS__/{flag=1;next}/^__DETAILS__$/{flag=0}flag' "$result_file")" + local_stderr="$(awk '/^local_stderr<<__STDERR__/{flag=1;next}/^__STDERR__$/{if(flag){flag=0; exit}}flag' "$result_file")" + prod_stderr="$(awk 'found && /^__STDERR__$/ {exit} /^prod_stderr<<__STDERR__$/ {found=1; next} found {print}' "$result_file")" + + if [[ "$status" == "pass" ]]; then + PASS_COUNT=$((PASS_COUNT + 1)) + printf '\r[%s] %4d/%-4d | pass:%d fail:%d' \ + "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$FAIL_COUNT" + if (( CASE_INDEX == TOTAL_CASES )); then + printf '\n' + fi + elif [[ "$status" == "accepted" ]]; then + ACCEPTED_COUNT=$((ACCEPTED_COUNT + 1)) + printf '\r\033[K' + printf '[%s] %4d/%-4d | pass:%d accepted:%d fail:%d\n' \ + "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$ACCEPTED_COUNT" "$FAIL_COUNT" + printf ' ACCEPT %s\n' "$case_name" + printf ' %s%s%s\n' "$path" "${query:+?$query}" "" + printf ' %s\n' "$accepted_reason" + else + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf '\r\033[K' + printf '[%s] %4d/%-4d | pass:%d fail:%d\n' \ + "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$FAIL_COUNT" + printf ' FAIL %s\n' "$case_name" + printf ' %s%s%s\n' "$path" "${query:+?$query}" "" + printf '%s' "$details" + if [[ -n "$local_stderr" || -n "$prod_stderr" ]]; then + printf ' stderr\n' + printf ' local=%s\n' "$local_stderr" + printf ' prod=%s\n' "$prod_stderr" + fi + if [[ "${mismatch_count:-0}" == "0" ]]; then + printf ' mismatch detected\n' + fi + fi +} + +stop_requested() { + [[ "$FAIL_FAST" != "false" && "$FAIL_COUNT" -gt 0 ]] +} + +wait_for_one_job() { + local pid done_pid result_file + while :; do + for pid in "${!JOB_RESULT_FILES[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" || true + done_pid="$pid" + result_file="${JOB_RESULT_FILES[$pid]}" + unset "JOB_RESULT_FILES[$pid]" + CASE_INDEX=$((CASE_INDEX + 1)) + print_case_result "$result_file" + rm -f "$result_file" + return + fi + done + sleep 0.05 + done +} + +drain_jobs() { + while ((${#JOB_RESULT_FILES[@]} > 0)); do + wait_for_one_job + if stop_requested; then + for pid in "${!JOB_RESULT_FILES[@]}"; do + kill "$pid" 2>/dev/null || true + done + JOB_RESULT_FILES=() + break + fi + done +} + +extract_versions() { + local device_id="$1" + local prerelease="$2" + local sku="$3" + local prefix="$4" + + local query_keys=("deviceId" "prerelease" "sku") + local query_values=("$device_id" "$prerelease" "$sku") + local query + query="$(join_query query_keys query_values)" + + curl_capture "$PROD_BASE" "/releases" "$query" "$prefix" + + python3 - "$prefix.body" <<'PY' +import json +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +try: + payload = json.loads(path.read_text(errors="replace")) +except Exception: + print("") + print("") + raise SystemExit(0) + +print(payload.get("appVersion", "")) +print(payload.get("systemVersion", "")) +PY +} + +build_value_set() { + local exact_version="$1" + local prerelease_version="$2" + local values=("__omit__" "*") + + if [[ -n "$exact_version" ]]; then + values+=("$exact_version") + fi + if [[ -n "$prerelease_version" && "$prerelease_version" != "$exact_version" ]]; then + values+=("$prerelease_version") + fi + + printf '%s\n' "${values[@]}" | awk '!seen[$0]++' +} + +run_case() { + local case_name="$1" + local path="$2" + local -n case_keys_ref=$3 + local -n case_values_ref=$4 + local query + + CASE_COUNT=$((CASE_COUNT + 1)) + query="$(join_query case_keys_ref case_values_ref)" + + local safe_case + safe_case="$(printf '%s' "$case_name" | tr ' /?=&' '_____')" + local result_file="$TMP_DIR/${safe_case}.result" + + run_case_worker "$case_name" "$path" "$query" "$safe_case" "$result_file" & + JOB_RESULT_FILES[$!]="$result_file" + + while ((${#JOB_RESULT_FILES[@]} >= MAX_PARALLEL)); do + wait_for_one_job + if stop_requested; then + return + fi + done +} + +log "Comparing release endpoints" +log " local: $LOCAL_BASE" +log " prod: $PROD_BASE" +log " deviceIds: ${DEVICE_IDS[*]}" + +mapfile -t stable_versions < <(extract_versions "${DEVICE_IDS[0]}" "__omit__" "__omit__" "$TMP_DIR/baseline-stable") +mapfile -t prerelease_versions < <(extract_versions "${DEVICE_IDS[0]}" "true" "__omit__" "$TMP_DIR/baseline-prerelease") + +STABLE_APP_VERSION="${stable_versions[0]:-}" +STABLE_SYSTEM_VERSION="${stable_versions[1]:-}" +PRERELEASE_APP_VERSION="${prerelease_versions[0]:-}" +PRERELEASE_SYSTEM_VERSION="${prerelease_versions[1]:-}" + +mapfile -t APP_VERSION_VALUES < <(build_value_set "$STABLE_APP_VERSION" "$PRERELEASE_APP_VERSION") +mapfile -t SYSTEM_VERSION_VALUES < <(build_value_set "$STABLE_SYSTEM_VERSION" "$PRERELEASE_SYSTEM_VERSION") + +TOTAL_CASES=$(( ${#DEVICE_IDS[@]} * ${#TRISTATE_VALUES[@]} * ${#APP_VERSION_VALUES[@]} * ${#SYSTEM_VERSION_VALUES[@]} * ${#DEFAULT_SKUS[@]} + ${#TRISTATE_VALUES[@]} * ${#DEFAULT_SKUS[@]} * 2 )) +declare -A JOB_RESULT_FILES=() +log " total cases: $TOTAL_CASES" +log " parallel: $MAX_PARALLEL" +log " failFast: $FAIL_FAST" +log + +for device_id in "${DEVICE_IDS[@]}"; do + for prerelease in "${TRISTATE_VALUES[@]}"; do + for app_version in "${APP_VERSION_VALUES[@]}"; do + for system_version in "${SYSTEM_VERSION_VALUES[@]}"; do + for sku in "${DEFAULT_SKUS[@]}"; do + if stop_requested; then + break 5 + fi + query_keys=("deviceId" "prerelease" "appVersion" "systemVersion" "sku") + query_values=("$device_id" "$prerelease" "$app_version" "$system_version" "$sku") + run_case \ + "GET /releases deviceId=$device_id prerelease=$prerelease appVersion=$app_version systemVersion=$system_version sku=$sku" \ + "/releases" \ + query_keys \ + query_values + done + done + done + done +done + +for prerelease in "${TRISTATE_VALUES[@]}"; do + for sku in "${DEFAULT_SKUS[@]}"; do + if stop_requested; then + break 2 + fi + query_keys=("prerelease" "sku") + query_values=("$prerelease" "$sku") + run_case \ + "GET /releases/app/latest prerelease=$prerelease sku=$sku" \ + "/releases/app/latest" \ + query_keys \ + query_values + run_case \ + "GET /releases/system_recovery/latest prerelease=$prerelease sku=$sku" \ + "/releases/system_recovery/latest" \ + query_keys \ + query_values + done +done + +drain_jobs + +log +log "Summary" +log " cases: $CASE_COUNT" +log " pass: $PASS_COUNT" +log " accept: $ACCEPTED_COUNT" +log " fail: $FAIL_COUNT" + +if ((FAIL_COUNT > 0)); then + exit 1 +fi diff --git a/scripts/seed.ts b/scripts/seed.ts index a628276..3fa4caf 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -2,6 +2,16 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); +type ReleaseType = "app" | "system"; + +// Pre-SKU artifacts are jetkvm-v2 only; future SKUs need explicit +// skus// uploads, registered via scripts/sync-releases.ts. +const LEGACY_COMPATIBLE_SKUS = ["jetkvm-v2"]; + +function compatibleSkusForRelease(_type: ReleaseType): string[] { + return LEGACY_COMPATIBLE_SKUS; +} + // Development test users const users = [ { googleId: "dev-user-1", email: "dev@example.com", picture: null }, @@ -23,7 +33,16 @@ const turnActivities = [ ]; // Production release snapshot -const releases = [ +interface SeedRelease { + version: string; + type: ReleaseType; + rolloutPercentage: number; + url: string; + hash: string; + createdAt: Date; +} + +const releases: SeedRelease[] = [ { version: "0.2.6", type: "app", rolloutPercentage: 100, url: "https://update.jetkvm.com/app/0.2.6/jetkvm_app", hash: "4b121195aa9dae9bd4ae7d1e69f49383510f9552cd9a9edd1a9f92c71e128f9c", createdAt: new Date("2024-09-27T11:41:59.669Z") }, { version: "0.2.7", type: "app", rolloutPercentage: 100, url: "https://update.jetkvm.com/app/0.2.7/jetkvm_app", hash: "2dbcc5a7bc1cc7196b458e633f654b521351eda66764b7a6d6a04f60a17347ca", createdAt: new Date("2024-09-27T11:59:32.279Z") }, { version: "0.1.7", type: "system", rolloutPercentage: 100, url: "https://update.jetkvm.com/system/0.1.7/system.tar", hash: "194287cf911801852cdc57aa9e8c9cfa59bf6c27feb5ae260f35bcfa895789e3", createdAt: new Date("2024-10-01T20:00:03.780Z") }, @@ -154,7 +173,20 @@ async function seedReleases(): Promise { return; } - await prisma.release.createMany({ data: releases }); + for (const release of releases) { + await prisma.release.create({ + data: { + ...release, + artifacts: { + create: { + url: release.url, + hash: release.hash, + compatibleSkus: compatibleSkusForRelease(release.type), + }, + }, + }, + }); + } console.log(`[seed] Release: created ${releases.length} records`); } diff --git a/scripts/sync-releases.ts b/scripts/sync-releases.ts new file mode 100644 index 0000000..8ae0624 --- /dev/null +++ b/scripts/sync-releases.ts @@ -0,0 +1,276 @@ +import { + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; +import { PrismaClient } from "@prisma/client"; +import semver from "semver"; + +import { streamToString } from "../src/helpers"; + +type ReleaseType = "app" | "system"; + +const DEFAULT_SKU = "jetkvm-v2"; +const KNOWN_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; + +interface SyncClients { + prisma: PrismaClient; + s3Client: S3Client; +} + +interface SyncConfig { + bucketName: string; + baseUrl: string; + skus?: string[]; +} + +interface ReleaseArtifactInput { + url: string; + hash: string; + compatibleSkus: string[]; +} + +function artifactName(type: ReleaseType): string { + return type === "app" ? "jetkvm_app" : "system.tar"; +} + +// Pre-SKU artifacts (no skus/ folder) are only safe on the original jetkvm-v2. +// Other SKUs require an explicit skus// upload to opt in. +function legacyCompatibleSkus(): string[] { + return [DEFAULT_SKU]; +} + +function isS3NotFound(error: any): boolean { + return ( + error.name === "NotFound" || + error.name === "NoSuchKey" || + error.$metadata?.httpStatusCode === 404 + ); +} + +async function s3ObjectExists( + s3Client: S3Client, + bucketName: string, + key: string, +): Promise { + try { + await s3Client.send(new HeadObjectCommand({ Bucket: bucketName, Key: key })); + return true; + } catch (error: any) { + if (isS3NotFound(error)) { + return false; + } + throw error; + } +} + +async function versionHasSkuSupport( + s3Client: S3Client, + bucketName: string, + type: ReleaseType, + version: string, +): Promise { + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: `${type}/${version}/skus/`, + MaxKeys: 1, + }), + ); + return (response.Contents?.length ?? 0) > 0; +} + +async function readHash( + s3Client: S3Client, + bucketName: string, + artifactPath: string, +): Promise { + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: `${artifactPath}.sha256`, + }), + ); + return streamToString(response.Body); + } catch (error: any) { + if (isS3NotFound(error)) { + return undefined; + } + throw error; + } +} + +function addArtifact( + artifactsByUrl: Map, + url: string, + hash: string, + sku: string, +): void { + const artifact = artifactsByUrl.get(url); + if (artifact) { + if (!artifact.compatibleSkus.includes(sku)) { + artifact.compatibleSkus.push(sku); + } + return; + } + + artifactsByUrl.set(url, { url, hash, compatibleSkus: [sku] }); +} + +export async function collectReleaseArtifacts( + clients: Pick, + config: SyncConfig, + type: ReleaseType, + version: string, +): Promise { + const skus = config.skus ?? KNOWN_SKUS; + const artifactFileName = artifactName(type); + + if (!(await versionHasSkuSupport(clients.s3Client, config.bucketName, type, version))) { + const artifactPath = `${type}/${version}/${artifactFileName}`; + const hash = await readHash(clients.s3Client, config.bucketName, artifactPath); + if (!hash) { + return []; + } + + return [ + { + url: `${config.baseUrl}/${artifactPath}`, + hash, + compatibleSkus: legacyCompatibleSkus(), + }, + ]; + } + + const artifactsByUrl = new Map(); + for (const sku of skus) { + const artifactPath = `${type}/${version}/skus/${sku}/${artifactFileName}`; + if (!(await s3ObjectExists(clients.s3Client, config.bucketName, artifactPath))) { + continue; + } + + const hash = await readHash(clients.s3Client, config.bucketName, artifactPath); + if (!hash) { + continue; + } + addArtifact(artifactsByUrl, `${config.baseUrl}/${artifactPath}`, hash, sku); + } + + return Array.from(artifactsByUrl.values()); +} + +async function listStableVersions( + s3Client: S3Client, + bucketName: string, + type: ReleaseType, +): Promise { + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: `${type}/`, + Delimiter: "/", + }), + ); + + return (response.CommonPrefixes ?? []) + .map(cp => cp.Prefix?.split("/")[1]) + .filter((version): version is string => Boolean(version)) + .filter( + version => Boolean(semver.valid(version)) && semver.prerelease(version) === null, + ) + .sort(semver.compare); +} + +async function syncRelease( + prisma: PrismaClient, + type: ReleaseType, + version: string, + artifacts: ReleaseArtifactInput[], +): Promise { + if (artifacts.length === 0) { + console.log(`[sync-releases] ${type} ${version}: skipped, no compatible artifacts`); + return; + } + + // Sync only registers brand-new releases. Existing rows (rollout state, URLs, + // artifact compatibility) are left untouched — backfills/repairs are handled + // by one-off scripts so a routine sync run can never rewrite production data. + const existing = await prisma.release.findUnique({ + where: { version_type: { version, type } }, + select: { id: true }, + }); + + if (existing) { + console.log(`[sync-releases] ${type} ${version}: already synced, skipping`); + return; + } + + const primaryArtifact = artifacts[0]; + await prisma.release.create({ + data: { + version, + type, + rolloutPercentage: 10, + url: primaryArtifact.url, + hash: primaryArtifact.hash, + artifacts: { + create: artifacts.map(artifact => ({ + url: artifact.url, + hash: artifact.hash, + compatibleSkus: artifact.compatibleSkus, + })), + }, + }, + }); + + console.log( + `[sync-releases] ${type} ${version}: created with ${artifacts.length} artifact(s)`, + ); +} + +export async function syncReleases( + clients: SyncClients, + config: SyncConfig, +): Promise { + for (const type of ["app", "system"] as const) { + const versions = await listStableVersions(clients.s3Client, config.bucketName, type); + + for (const version of versions) { + const artifacts = await collectReleaseArtifacts(clients, config, type, version); + await syncRelease(clients.prisma, type, version, artifacts); + } + } +} + +async function main(): Promise { + const prisma = new PrismaClient(); + const s3Client = new S3Client({ + endpoint: process.env.R2_ENDPOINT!, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + region: "auto", + }); + + try { + await syncReleases( + { prisma, s3Client }, + { + bucketName: process.env.R2_BUCKET!, + baseUrl: process.env.R2_CDN_URL!, + }, + ); + } finally { + await prisma.$disconnect(); + } +} + +if (require.main === module) { + main().catch(error => { + console.error("[sync-releases] failed", error); + process.exit(1); + }); +} diff --git a/src/releases.ts b/src/releases.ts index 703155b..be53836 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -20,6 +20,7 @@ import { import { z, ZodError } from "zod"; const DEFAULT_SKU = "jetkvm-v2"; +type ReleaseType = "app" | "system"; /** Query param schema builders for common patterns */ const queryString = () => @@ -51,7 +52,7 @@ type LatestQuery = z.infer; /** * Schema for the main Retrieve endpoint. - * Requires deviceId and includes version constraints and forceUpdate flag. + * Requires deviceId and includes version constraints. */ const retrieveQuerySchema = z.object({ deviceId: z.string({ error: "Device ID is required" }).min(1, "Device ID is required"), @@ -59,7 +60,6 @@ const retrieveQuerySchema = z.object({ appVersion: queryString(), systemVersion: queryString(), sku: querySku(), - forceUpdate: queryBoolean(), }); type RetrieveQuery = z.infer; @@ -87,6 +87,15 @@ export interface ReleaseMetadata { _maxSatisfying?: string; } +interface DbRelease { + version: string; + rolloutPercentage: number; + artifacts: { + url: string; + hash: string; + }[]; +} + const s3Client = new S3Client({ endpoint: process.env.R2_ENDPOINT!, credentials: { @@ -379,6 +388,44 @@ function toRelease( return release as Release; } +function objectKeyFromArtifactUrl(artifactUrl: string): string { + const parsed = new URL(artifactUrl); + return decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); +} + +async function resolveSigUrlFromArtifactUrl( + artifactUrl: string, +): Promise { + const cacheKey = `artifact-url-${artifactUrl}`; + const cached = sigUrlCache.get(cacheKey); + if (cached !== undefined) return cached === MISSING_SIG_URL ? undefined : cached; + + const sigUrl = `${artifactUrl}.sig`; + try { + const sigKey = `${objectKeyFromArtifactUrl(artifactUrl)}.sig`; + if (await s3ObjectExists(sigKey)) { + sigUrlCache.set(cacheKey, sigUrl); + return sigUrl; + } + } catch (error) { + console.error(`Failed to resolve sig URL for ${artifactUrl}:`, error); + return undefined; + } + + sigUrlCache.set(cacheKey, MISSING_SIG_URL); + return undefined; +} + +async function addStableSigUrls(release: Release): Promise { + const [appSigUrl, systemSigUrl] = await Promise.all([ + release.appUrl ? resolveSigUrlFromArtifactUrl(release.appUrl) : undefined, + release.systemUrl ? resolveSigUrlFromArtifactUrl(release.systemUrl) : undefined, + ]); + + if (appSigUrl) release.appSigUrl = appSigUrl; + if (systemSigUrl) release.systemSigUrl = systemSigUrl; +} + async function getReleaseFromS3( includePrerelease: boolean, { @@ -403,32 +450,117 @@ async function isDeviceEligibleForLatestRelease( return getDeviceRolloutBucket(deviceId) < rolloutPercentage; } -async function getDefaultRelease(type: "app" | "system") { +function compatibleArtifactSelect(sku: string) { + return { + where: { compatibleSkus: { has: sku } }, + select: { url: true, hash: true }, + orderBy: { id: "asc" as const }, + take: 1, + }; +} + +function compatibleReleaseSelect(sku: string) { + return { + version: true, + rolloutPercentage: true, + artifacts: compatibleArtifactSelect(sku), + } as const; +} + +function dbReleaseToMetadata( + release: DbRelease, + sku: string, + maxSatisfying?: string, +): ReleaseMetadata { + const artifact = release.artifacts[0]; + if (!artifact) { + throw new NotFoundError( + `Version ${release.version} predates SKU support and cannot serve SKU "${sku}"`, + ); + } + + return { + version: release.version, + url: artifact.url, + hash: artifact.hash, + _maxSatisfying: maxSatisfying, + }; +} + +async function getDefaultRelease(type: ReleaseType, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ - where: { rolloutPercentage: 100, type }, - select: { version: true, url: true, hash: true }, + where: { type, rolloutPercentage: 100 }, + select: compatibleReleaseSelect(sku), }); if (rolledOutReleases.length === 0) { - throw new InternalServerError(`No default release found for type ${type}`); + throw new InternalServerError( + `No default release found for type ${type} and SKU "${sku}"`, + ); + } + + // Only consider releases that ship a binary for this SKU. Without this, + // the newest 100%-rolled-out release wins even if it has no compatible + // artifact, masking older releases that do. + const compatibleReleases = rolledOutReleases.filter(r => r.artifacts.length > 0); + + if (compatibleReleases.length === 0) { + throw new NotFoundError( + `No default ${type} release available for SKU "${sku}"`, + ); } - // Get the latest default version from the rolled out releases const latestVersion = semver.maxSatisfying( - rolledOutReleases.map(r => r.version), + compatibleReleases.map(r => r.version), "*", ) as string; - // Get the release with the latest default version - const latestDefaultRelease = rolledOutReleases.find(r => r.version === latestVersion); + const latestDefaultRelease = compatibleReleases.find(r => r.version === latestVersion); if (!latestDefaultRelease) { - throw new InternalServerError(`No default release found for type ${type}`); + throw new InternalServerError( + `No default release found for type ${type} and SKU "${sku}"`, + ); } return latestDefaultRelease; } +async function getLatestRelease(type: ReleaseType, sku: string): Promise { + return getReleaseByRange(type, sku, "*"); +} + +async function getReleaseByRange( + type: ReleaseType, + sku: string, + range: string, +): Promise { + const releases = await prisma.release.findMany({ + where: { type }, + select: compatibleReleaseSelect(sku), + }); + + if (releases.length === 0) { + throw new NotFoundError(`No release found for type ${type} and SKU "${sku}"`); + } + + const latestVersion = semver.maxSatisfying( + releases.map(r => r.version), + range, + ) as string; + + if (!latestVersion) { + throw new NotFoundError(`No ${type} release found that satisfies ${range}`); + } + + const latestRelease = releases.find(r => r.version === latestVersion); + if (!latestRelease) { + throw new NotFoundError(`No ${type} release found that satisfies ${range}`); + } + + return latestRelease; +} + export async function Retrieve(req: Request, res: Response) { const query = parseQuery(retrieveQuerySchema, req); @@ -436,96 +568,80 @@ export async function Retrieve(req: Request, res: Response) { const systemVersion = toSemverRange(query.systemVersion); const skipRollout = appVersion !== "*" || systemVersion !== "*"; - // Get the latest release from S3 - let remoteRelease: Release; - try { - remoteRelease = await getReleaseFromS3(query.prerelease, { - appVersion, - systemVersion, - sku: query.sku, - }); - } catch (error) { - console.error(error); - if (error instanceof NotFoundError) { - throw error; + // Prereleases are not imported into the DB by the stable sync script. + if (query.prerelease) { + let remoteRelease: Release; + try { + remoteRelease = await getReleaseFromS3(query.prerelease, { + appVersion, + systemVersion, + sku: query.sku, + }); + } catch (error) { + console.error(error); + if (error instanceof NotFoundError) { + throw error; + } + throw new InternalServerError(`Failed to get the latest release from S3: ${error}`); } - throw new InternalServerError(`Failed to get the latest release from S3: ${error}`); - } - - // If the request is for prereleases, ignore the rollout percentage and just return the latest release - // This is useful for the OTA updater to get the latest prerelease version - // This also prevents us from storing the rollout percentage for prerelease versions - // If the version isn't a wildcard, we skip the rollout percentage check - if (query.prerelease || skipRollout) { await enrichWithSigUrls(remoteRelease, query.sku); return res.json(remoteRelease); } - // Fetch or create the latest app release - const latestAppRelease = await prisma.release.upsert({ - where: { version_type: { version: remoteRelease.appVersion, type: "app" } }, - update: {}, - create: { - version: remoteRelease.appVersion, - rolloutPercentage: 10, - url: remoteRelease.appUrl, - type: "app", - hash: remoteRelease.appHash, - }, - select: { version: true, url: true, rolloutPercentage: true, hash: true }, - }); - - // Fetch or create the latest system release - const latestSystemRelease = await prisma.release.upsert({ - where: { version_type: { version: remoteRelease.systemVersion, type: "system" } }, - update: {}, - create: { - version: remoteRelease.systemVersion, - rolloutPercentage: 10, - url: remoteRelease.systemUrl, - type: "system", - hash: remoteRelease.systemHash, - }, - select: { version: true, url: true, rolloutPercentage: true, hash: true }, - }); + // Version-constrained stable requests skip rollout but still read DB metadata. + if (skipRollout) { + const responseJson = toRelease( + dbReleaseToMetadata( + await getReleaseByRange("app", query.sku, appVersion), + query.sku, + appVersion, + ), + dbReleaseToMetadata( + await getReleaseByRange("system", query.sku, systemVersion), + query.sku, + systemVersion, + ), + ); + await addStableSigUrls(responseJson); + return res.json(responseJson); + } - /* - Return the latest release if forceUpdate is true, bypassing rollout rules. - This occurs when a user manually checks for updates in the app UI. - Background update checks follow the normal rollout percentage rules, to ensure controlled, gradual deployment of updates. - */ - let responseJson: Release; - if (query.forceUpdate) { - responseJson = toRelease(latestAppRelease, latestSystemRelease); - } else { - const defaultAppRelease = await getDefaultRelease("app"); - const defaultSystemRelease = await getDefaultRelease("system"); + const [latestAppRelease, latestSystemRelease, defaultAppRelease, defaultSystemRelease] = + await Promise.all([ + getLatestRelease("app", query.sku), + getLatestRelease("system", query.sku), + getDefaultRelease("app", query.sku), + getDefaultRelease("system", query.sku), + ]); - responseJson = toRelease(defaultAppRelease, defaultSystemRelease); + // Background update checks follow rollout percentages so new releases roll + // out gradually. Devices outside the bucket fall back to the default (the + // newest 100%-rolled-out release). + const responseJson = toRelease( + dbReleaseToMetadata(defaultAppRelease, query.sku), + dbReleaseToMetadata(defaultSystemRelease, query.sku), + ); - if ( - await isDeviceEligibleForLatestRelease( - latestAppRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setAppRelease(responseJson, latestAppRelease); - } + if ( + await isDeviceEligibleForLatestRelease( + latestAppRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setAppRelease(responseJson, dbReleaseToMetadata(latestAppRelease, query.sku)); + } - if ( - await isDeviceEligibleForLatestRelease( - latestSystemRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setSystemRelease(responseJson, latestSystemRelease); - } + if ( + await isDeviceEligibleForLatestRelease( + latestSystemRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setSystemRelease(responseJson, dbReleaseToMetadata(latestSystemRelease, query.sku)); } - // DB records don't store sigUrl. Resolve from S3 for the versions being served. - // The device requires sigUrl for stable (non-prerelease) GPG signature verification. - await enrichWithSigUrls(responseJson, query.sku); + await addStableSigUrls(responseJson); return res.json(responseJson); } diff --git a/test/releases.test.ts b/test/releases.test.ts index 85eb617..723c15b 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Request, Response } from "express"; -import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; -import { s3Mock, createAsyncIterable, testPrisma, seedReleases, setRollout, resetToSeedData } from "./setup"; +import { + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, +} from "@aws-sdk/client-s3"; +import { s3Mock, createAsyncIterable, testPrisma, resetToSeedData } from "./setup"; import { BadRequestError, NotFoundError, InternalServerError } from "../src/errors"; // Import the module under test after setup @@ -11,7 +15,10 @@ import { RetrieveLatestSystemRecovery, clearCaches, } from "../src/releases"; -import { getDeviceRolloutBucket } from "../src/helpers"; + +const DEFAULT_SKU = "jetkvm-v2"; +const SDMMC_SKU = "jetkvm-v2-sdmmc"; +type ReleaseType = "app" | "system"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -21,7 +28,11 @@ function createMockRequest(query: Record = {}): Requ } // Helper to create mock Response -function createMockResponse(): Response & { _json: any; _redirectUrl: string; _redirectStatus: number } { +function createMockResponse(): Response & { + _json: any; + _redirectUrl: string; + _redirectStatus: number; +} { const res = { _json: null, _redirectUrl: "", @@ -35,19 +46,28 @@ function createMockResponse(): Response & { _json: any; _redirectUrl: string; _r this._redirectUrl = url; return this; }), - } as unknown as Response & { _json: any; _redirectUrl: string; _redirectStatus: number }; + } as unknown as Response & { + _json: any; + _redirectUrl: string; + _redirectStatus: number; + }; return res; } // Mock S3 responses for listing versions function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({ - CommonPrefixes: versions.map((v) => ({ Prefix: `${prefix}/${v}/` })), + CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })), }); } // Mock S3 hash file response for legacy versions (no SKU support) -function mockS3HashFile(prefix: "app" | "system", version: string, hash: string, opts?: { hasSig?: boolean }) { +function mockS3HashFile( + prefix: "app" | "system", + version: string, + hash: string, + opts?: { hasSig?: boolean }, +) { const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; const artifactPath = `${prefix}/${version}/${fileName}`; @@ -97,14 +117,13 @@ function mockS3SkuVersion( } } - // Mock S3 for legacy version with file content (for redirect endpoints with hash verification) function mockS3LegacyVersionWithContent( prefix: "app" | "system", version: string, fileName: string, content: string, - hash: string + hash: string, ) { // Mock versionHasSkuSupport to return false (no SKU folders) s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ @@ -115,9 +134,11 @@ function mockS3LegacyVersionWithContent( s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({ Body: createAsyncIterable(content) as any, }); - s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({ - Body: createAsyncIterable(hash) as any, - }); + s3Mock + .on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }) + .resolves({ + Body: createAsyncIterable(hash) as any, + }); } // Mock S3 for SKU version with file content (for redirect endpoints with hash verification) @@ -127,7 +148,7 @@ function mockS3SkuVersionWithContent( sku: string, fileName: string, content: string, - hash: string + hash: string, ) { const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; @@ -150,24 +171,67 @@ function mockS3SkuVersionWithContent( }); } -function findDeviceIdOutsideRollout(threshold: number) { - for (let i = 0; i < 10000; i += 1) { - const candidate = `device-not-eligible-${i}`; - if (getDeviceRolloutBucket(candidate) >= threshold) { - return candidate; - } - } - throw new Error("Failed to find deviceId outside rollout bucket"); +function artifactFileName(type: ReleaseType) { + return type === "app" ? "jetkvm_app" : "system.tar"; } -function findDeviceIdInsideRollout(threshold: number) { - for (let i = 0; i < 10000; i += 1) { - const candidate = `device-eligible-${i}`; - if (getDeviceRolloutBucket(candidate) < threshold) { - return candidate; - } +function artifactPath(type: ReleaseType, version: string, sku = DEFAULT_SKU) { + const fileName = artifactFileName(type); + if (sku === DEFAULT_SKU) { + return `${type}/${version}/${fileName}`; } - throw new Error("Failed to find deviceId inside rollout bucket"); + return `${type}/${version}/skus/${sku}/${fileName}`; +} + +function artifactUrl(type: ReleaseType, version: string, sku = DEFAULT_SKU) { + return `https://cdn.test.com/${artifactPath(type, version, sku)}`; +} + +function mockArtifactSig(type: ReleaseType, version: string, sku = DEFAULT_SKU) { + s3Mock + .on(HeadObjectCommand, { Key: `${artifactPath(type, version, sku)}.sig` }) + .resolves({}); +} + +function releaseArtifact( + type: ReleaseType, + version: string, + sku = DEFAULT_SKU, + hash = `${type}-${version}-${sku}-hash`, +) { + return { + url: artifactUrl(type, version, sku), + hash, + compatibleSkus: [sku], + }; +} + +async function createDbRelease( + type: ReleaseType, + version: string, + rolloutPercentage: number, + artifacts = [releaseArtifact(type, version)], +) { + const primaryArtifact = artifacts[0]; + await testPrisma.release.create({ + data: { + version, + type, + rolloutPercentage, + url: primaryArtifact.url, + hash: primaryArtifact.hash, + artifacts: { create: artifacts }, + }, + }); +} + +async function createDbReleasePair(version: string, rolloutPercentage: number) { + await createDbRelease("app", version, rolloutPercentage); + await createDbRelease("system", version, rolloutPercentage); +} + +function jsonBody(res: { _json: unknown }) { + return JSON.parse(JSON.stringify(res._json)); } describe("Retrieve handler", () => { @@ -175,7 +239,9 @@ describe("Retrieve handler", () => { s3Mock.reset(); // Default: .sig files don't exist unless explicitly mocked per-key. // More specific .on(HeadObjectCommand, { Key }) mocks take precedence. - s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + s3Mock + .on(HeadObjectCommand) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); clearCaches(); }); @@ -199,7 +265,7 @@ describe("Retrieve handler", () => { describe("S3 error handling", () => { it("should throw NotFoundError when no versions exist in S3", async () => { - const req = createMockRequest({ deviceId: "device-123" }); + const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); const res = createMockResponse(); // Mock empty S3 response for both app and system @@ -209,15 +275,21 @@ describe("Retrieve handler", () => { }); it("should throw NotFoundError when no valid semver versions exist", async () => { - const req = createMockRequest({ deviceId: "device-123" }); + const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); const res = createMockResponse(); // Mock S3 with invalid version names s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ - CommonPrefixes: [{ Prefix: "app/invalid-version/" }, { Prefix: "app/not-semver/" }], + CommonPrefixes: [ + { Prefix: "app/invalid-version/" }, + { Prefix: "app/not-semver/" }, + ], }); s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ - CommonPrefixes: [{ Prefix: "system/invalid-version/" }, { Prefix: "system/not-semver/" }], + CommonPrefixes: [ + { Prefix: "system/invalid-version/" }, + { Prefix: "system/not-semver/" }, + ], }); await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); @@ -266,203 +338,187 @@ describe("Retrieve handler", () => { }); }); - describe("version constraints", () => { - it("should respect appVersion constraint", async () => { - const req = createMockRequest({ deviceId: "device-123", appVersion: "^1.0.0" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "2.0.0"]); - mockS3ListVersions("system", ["1.0.0", "2.0.0"]); - mockS3HashFile("app", "1.1.0", "app-hash-110"); - mockS3HashFile("system", "2.0.0", "system-hash-200"); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("1.1.0"); // Max satisfying ^1.0.0 - expect(res._json.systemVersion).toBe("2.0.0"); // No constraint, get latest + describe("stable DB-backed contract", () => { + beforeEach(async () => { + await resetToSeedData(); }); - it("should respect systemVersion constraint", async () => { - const req = createMockRequest({ deviceId: "device-123", systemVersion: "~1.0.0" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "2.0.0"]); - mockS3ListVersions("system", ["1.0.0", "1.0.5", "1.1.0", "2.0.0"]); - mockS3HashFile("app", "2.0.0", "app-hash-200"); - mockS3HashFile("system", "1.0.5", "system-hash-105"); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("2.0.0"); - expect(res._json.systemVersion).toBe("1.0.5"); // Max satisfying ~1.0.0 - }); + it("serves the latest fully rolled out release on background checks", async () => { + await createDbReleasePair("2.0.0", 100); + await createDbReleasePair("2.1.0", 0); + mockArtifactSig("app", "2.0.0"); + mockArtifactSig("system", "2.0.0"); - it("should skip rollout when version constraints are specified", async () => { - const req = createMockRequest({ - deviceId: "device-123", - appVersion: "1.0.0", - systemVersion: "1.0.0", - }); const res = createMockResponse(); - mockS3ListVersions("app", ["1.0.0", "2.0.0"]); - mockS3ListVersions("system", ["1.0.0", "2.0.0"]); - mockS3HashFile("app", "1.0.0", "app-hash-100"); - mockS3HashFile("system", "1.0.0", "system-hash-100"); - await setRollout("1.0.0", "app", 0); - await setRollout("1.0.0", "system", 0); - - await Retrieve(req, res); - - // Should return specified version directly (skipRollout=true) - expect(res._json.appVersion).toBe("1.0.0"); - expect(res._json.systemVersion).toBe("1.0.0"); + await Retrieve(createMockRequest({ deviceId: "stable-background-device" }), res); + + expect(jsonBody(res)).toMatchObject({ + appVersion: "2.0.0", + appUrl: artifactUrl("app", "2.0.0"), + appHash: "app-2.0.0-jetkvm-v2-hash", + appSigUrl: `${artifactUrl("app", "2.0.0")}.sig`, + systemVersion: "2.0.0", + systemUrl: artifactUrl("system", "2.0.0"), + systemHash: "system-2.0.0-jetkvm-v2-hash", + systemSigUrl: `${artifactUrl("system", "2.0.0")}.sig`, + }); }); - it("should throw NotFoundError when no version satisfies constraint", async () => { - const req = createMockRequest({ deviceId: "device-123", appVersion: "^5.0.0" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "2.0.0"]); + it("applies app and system rollout independently", async () => { + await createDbReleasePair("2.4.0", 100); + await createDbRelease("app", "2.5.0", 100); + await createDbRelease("system", "2.5.0", 0); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - }); - }); - - describe("SKU handling", () => { - it("should use legacy path when no SKU provided on legacy version", async () => { - // Pin versions to bypass rollout; SKU behavior is the only variable here. - const req = createMockRequest({ - deviceId: "device-123", - appVersion: "1.0.0", - systemVersion: "1.0.0", - }); const res = createMockResponse(); - mockS3ListVersions("app", ["1.0.0"]); - mockS3ListVersions("system", ["1.0.0"]); - mockS3HashFile("app", "1.0.0", "legacy-app-hash"); - mockS3HashFile("system", "1.0.0", "legacy-system-hash"); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("1.0.0"); - expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar"); - }); + await Retrieve(createMockRequest({ deviceId: "split-rollout-device" }), res); - it("should use legacy path when default SKU provided on legacy version", async () => { - // Pin versions to bypass rollout; SKU behavior is the only variable here. - const req = createMockRequest({ - deviceId: "device-123", - sku: "jetkvm-v2", - appVersion: "1.0.0", - systemVersion: "1.0.0", + expect(jsonBody(res)).toMatchObject({ + appVersion: "2.5.0", + appUrl: artifactUrl("app", "2.5.0"), + systemVersion: "2.4.0", + systemUrl: artifactUrl("system", "2.4.0"), }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0"]); - mockS3ListVersions("system", ["1.0.0"]); - mockS3HashFile("app", "1.0.0", "legacy-app-hash-2"); - mockS3HashFile("system", "1.0.0", "legacy-system-hash-2"); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("1.0.0"); - expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar"); }); - it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { - // Pin versions to bypass rollout; SKU behavior is the only variable here. - const req = createMockRequest({ - deviceId: "device-123", - sku: "jetkvm-2", - appVersion: "1.0.0", - systemVersion: "1.0.0", - }); + it("uses DB version ranges and bypasses rollout for constrained requests", async () => { + await createDbReleasePair("3.0.0", 100); + await createDbReleasePair("3.1.0", 0); + mockArtifactSig("app", "3.1.0"); + mockArtifactSig("system", "3.0.0"); + const res = createMockResponse(); - mockS3ListVersions("app", ["1.0.0"]); - mockS3ListVersions("system", ["1.0.0"]); - mockS3HashFile("app", "1.0.0", "legacy-app-hash-3"); - mockS3HashFile("system", "1.0.0", "legacy-system-hash-3"); + await Retrieve( + createMockRequest({ + deviceId: "pinned-device", + appVersion: "^3.0.0", + systemVersion: "3.0.0", + }), + res, + ); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow("predates SKU support"); + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.1.0", + appSigUrl: `${artifactUrl("app", "3.1.0")}.sig`, + systemVersion: "3.0.0", + systemSigUrl: `${artifactUrl("system", "3.0.0")}.sig`, + }); }); - it("should use SKU path when version has SKU support", async () => { - const req = createMockRequest({ - deviceId: "device-123", - sku: "jetkvm-2", - appVersion: "^2.0.0", - systemVersion: "^2.0.0", - }); + it("omits DB-backed stable sigUrl fields when sibling .sig objects are absent", async () => { + await createDbReleasePair("3.1.1", 100); + mockArtifactSig("app", "3.1.1"); + const res = createMockResponse(); - mockS3ListVersions("app", ["2.0.0"]); - mockS3ListVersions("system", ["2.0.0"]); - mockS3SkuVersion("app", "2.0.0", "jetkvm-2", "sku-app-hash"); - mockS3SkuVersion("system", "2.0.0", "jetkvm-2", "sku-system-hash"); + await Retrieve(createMockRequest({ deviceId: "stable-partial-sig-device" }), res); - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("2.0.0"); - expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/system.tar"); + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.1.1", + appSigUrl: `${artifactUrl("app", "3.1.1")}.sig`, + systemVersion: "3.1.1", + }); + expect(res._json.systemSigUrl).toBeUndefined(); }); - it("should use default SKU when no SKU provided on version with SKU support", async () => { - const req = createMockRequest({ - deviceId: "device-123", - appVersion: "^2.0.0", - systemVersion: "^2.0.0", - }); + it("selects the artifact compatible with the requested SKU", async () => { + await createDbRelease("app", "3.2.0", 100, [ + { + ...releaseArtifact("app", "3.2.0", DEFAULT_SKU), + compatibleSkus: [DEFAULT_SKU, SDMMC_SKU], + }, + ]); + await createDbRelease("system", "3.2.0", 100, [ + releaseArtifact("system", "3.2.0", DEFAULT_SKU, "system-default-hash"), + releaseArtifact("system", "3.2.0", SDMMC_SKU, "system-sdmmc-hash"), + ]); + const res = createMockResponse(); - mockS3ListVersions("app", ["2.0.0"]); - mockS3ListVersions("system", ["2.0.0"]); - mockS3SkuVersion("app", "2.0.0", "jetkvm-v2", "default-sku-app-hash"); - mockS3SkuVersion("system", "2.0.0", "jetkvm-v2", "default-sku-system-hash"); + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-device", + sku: SDMMC_SKU, + }), + res, + ); - await Retrieve(req, res); + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.2.0", + appUrl: artifactUrl("app", "3.2.0"), + systemVersion: "3.2.0", + systemUrl: artifactUrl("system", "3.2.0", SDMMC_SKU), + systemHash: "system-sdmmc-hash", + }); + }); - expect(res._json.appVersion).toBe("2.0.0"); - expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/system.tar"); + it("does not fall back when the latest release lacks a compatible artifact", async () => { + await createDbRelease("app", "3.3.0", 100, [ + { + ...releaseArtifact("app", "3.3.0", DEFAULT_SKU), + compatibleSkus: [DEFAULT_SKU, SDMMC_SKU], + }, + ]); + await createDbRelease("app", "3.3.1", 100, [ + { + ...releaseArtifact("app", "3.3.1", DEFAULT_SKU), + compatibleSkus: [DEFAULT_SKU, SDMMC_SKU], + }, + ]); + await createDbRelease("system", "3.3.0", 100, [ + releaseArtifact("system", "3.3.0", DEFAULT_SKU, "system-default-hash"), + releaseArtifact("system", "3.3.0", SDMMC_SKU, "system-sdmmc-hash"), + ]); + await createDbRelease("system", "3.3.1", 100); + + await expect( + Retrieve( + createMockRequest({ + deviceId: "sdmmc-compatible-fallback-device", + sku: SDMMC_SKU, + }), + createMockResponse(), + ), + ).rejects.toThrow( + 'Version 3.3.1 predates SKU support and cannot serve SKU "jetkvm-v2-sdmmc"', + ); }); - it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { - const req = createMockRequest({ - deviceId: "device-123", - sku: "jetkvm-3", - appVersion: "^2.0.0", - systemVersion: "^2.0.0", - }); + it("does not discover or create stable releases from S3", async () => { + await createDbReleasePair("3.4.0", 100); + s3Mock + .on(ListObjectsV2Command) + .rejects(new Error("stable requests should not list S3")); + s3Mock + .on(GetObjectCommand) + .rejects(new Error("stable requests should not read S3")); + const res = createMockResponse(); - mockS3ListVersions("app", ["2.0.0"]); - mockS3ListVersions("system", ["2.0.0"]); + await Retrieve( + createMockRequest({ deviceId: "db-only-device" }), + res, + ); - // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't - s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], - }); - s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ - Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/system.tar" }], - }); - s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ - name: "NoSuchKey", - $metadata: { httpStatusCode: 404 }, - }); - s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/system.tar" }).rejects({ - name: "NoSuchKey", - $metadata: { httpStatusCode: 404 }, + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.4.0", + systemVersion: "3.4.0", }); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); + }); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow("is not available for version"); + it("fails when no fully rolled out default exists for background checks", async () => { + await testPrisma.release.updateMany({ data: { rolloutPercentage: 50 } }); + + await expect( + Retrieve( + createMockRequest({ deviceId: "no-default-device" }), + createMockResponse(), + ), + ).rejects.toThrow(InternalServerError); }); }); @@ -484,7 +540,9 @@ describe("Retrieve handler", () => { await Retrieve(req, res); expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/6.0.0/jetkvm_app.sig"); - expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/6.0.0/system.tar.sig"); + expect(res._json.systemSigUrl).toBe( + "https://cdn.test.com/system/6.0.0/system.tar.sig", + ); }); it("should omit sigUrl when .sig file does not exist", async () => { @@ -510,6 +568,7 @@ describe("Retrieve handler", () => { it("should include sigUrl with SKU path when .sig file exists", async () => { const req = createMockRequest({ deviceId: "device-sku-sig", + prerelease: "true", sku: "jetkvm-2", appVersion: "^8.0.0", systemVersion: "^8.0.0", @@ -519,214 +578,33 @@ describe("Retrieve handler", () => { mockS3ListVersions("app", ["8.0.0"]); mockS3ListVersions("system", ["8.0.0"]); mockS3SkuVersion("app", "8.0.0", "jetkvm-2", "sku-sig-app-hash", { hasSig: true }); - mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { hasSig: true }); - - await Retrieve(req, res); - - expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig"); - expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig"); - }); - }); - - describe("forceUpdate mode", () => { - it("should return latest release when forceUpdate=true", async () => { - // Use unique version constraints to get unique cache keys - const req = createMockRequest({ - deviceId: "device-force", - forceUpdate: "true", - appVersion: "^1.5.0", - systemVersion: "^1.5.0", - }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.5.5"]); - mockS3ListVersions("system", ["1.0.0", "1.5.5"]); - mockS3HashFile("app", "1.5.5", "force-app-hash"); - mockS3HashFile("system", "1.5.5", "force-system-hash"); - - await Retrieve(req, res); - - // forceUpdate should return the latest version from S3 (upserted in DB) - expect(res._json.appVersion).toBe("1.5.5"); - expect(res._json.systemVersion).toBe("1.5.5"); - }); - - it("should include sigUrl when forceUpdate=true and .sig file exists", async () => { - const req = createMockRequest({ - deviceId: "device-force-sig", - forceUpdate: "true", + mockS3SkuVersion("system", "8.0.0", "jetkvm-2", "sku-sig-system-hash", { + hasSig: true, }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["10.0.0"]); - mockS3ListVersions("system", ["10.0.0"]); - mockS3HashFile("app", "10.0.0", "force-sig-app-hash", { hasSig: true }); - mockS3HashFile("system", "10.0.0", "force-sig-system-hash", { hasSig: true }); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("10.0.0"); - expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/10.0.0/jetkvm_app.sig"); - expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/10.0.0/system.tar.sig"); - }); - }); - - describe("rollout logic", () => { - beforeEach(async () => { - // Reset to baseline seed data before each rollout test - await resetToSeedData(); - }); - - it("should return default release for device not in rollout percentage", async () => { - // Explicitly set rollout: 1.1.0 at 100% (default), 1.2.0 at 10% (latest) - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 10); - await setRollout("1.2.0", "system", 10); - - // Use a device ID that will NOT be eligible (hash % 100 >= 10) - const deviceId = findDeviceIdOutsideRollout(10); - const req = createMockRequest({ deviceId }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req, res); - - // Device not in 10% rollout should get 1.1.0 (latest 100% default) - expect(res._json.appVersion).toBe("1.1.0"); - expect(res._json.systemVersion).toBe("1.1.0"); - }); - - it("should return latest release when device is in rollout percentage", async () => { - // Set 1.2.0 to 10% rollout and pick an eligible device - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 10); - await setRollout("1.2.0", "system", 10); - - const deviceId = findDeviceIdInsideRollout(10); - const req = createMockRequest({ deviceId }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req, res); - - // With a device in the rollout bucket, it should get the latest - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.2.0"); - }); - - it("should return default when rollout is 0%", async () => { - // Set 1.2.0 to 0% rollout - no devices should get it - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 0); - await setRollout("1.2.0", "system", 0); - - const req = createMockRequest({ deviceId: "any-device" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req, res); - - // With 0% rollout, all devices get the default (1.1.0) - expect(res._json.appVersion).toBe("1.1.0"); - expect(res._json.systemVersion).toBe("1.1.0"); - }); - - it("should evaluate app and system rollout independently", async () => { - // Set different rollouts: app at 100%, system at 0% - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 100); // All devices get latest app - await setRollout("1.2.0", "system", 0); // No devices get latest system - - const req = createMockRequest({ deviceId: "any-device" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req, res); - - // App gets 1.2.0 (100% rollout), system gets 1.1.0 (default, since 1.2.0 is 0%) - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.1.0"); - }); - - it("should include sigUrl for rollout-eligible device when .sig file exists", async () => { - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 100); - await setRollout("1.2.0", "system", 100); - - const deviceId = findDeviceIdInsideRollout(100); - const req = createMockRequest({ deviceId }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "rollout-sig-app-hash", { hasSig: true }); - mockS3HashFile("system", "1.2.0", "rollout-sig-system-hash", { hasSig: true }); await Retrieve(req, res); - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.appSigUrl).toBe("https://cdn.test.com/app/1.2.0/jetkvm_app.sig"); - expect(res._json.systemSigUrl).toBe("https://cdn.test.com/system/1.2.0/system.tar.sig"); - }); - }); - - describe("default release handling", () => { - beforeEach(async () => { - await resetToSeedData(); - }); - - it("should throw InternalServerError when no default release exists", async () => { - // Set all releases to non-100% rollout (no default available) - await setRollout("1.0.0", "app", 50); - await setRollout("1.1.0", "app", 50); - await setRollout("1.2.0", "app", 50); - await setRollout("1.0.0", "system", 50); - await setRollout("1.1.0", "system", 50); - await setRollout("1.2.0", "system", 50); - - const req = createMockRequest({ deviceId: "device-123" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await expect(Retrieve(req, res)).rejects.toThrow(InternalServerError); + expect(res._json.appSigUrl).toBe( + "https://cdn.test.com/app/8.0.0/skus/jetkvm-2/jetkvm_app.sig", + ); + expect(res._json.systemSigUrl).toBe( + "https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig", + ); }); }); describe("S3 non-NotFoundError handling", () => { it("should wrap non-NotFoundError in InternalServerError", async () => { - const req = createMockRequest({ deviceId: "device-123" }); + const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); const res = createMockResponse(); // Mock S3 to throw a generic error (e.g., network error) s3Mock.on(ListObjectsV2Command).rejects(new Error("Network timeout")); await expect(Retrieve(req, res)).rejects.toThrow(InternalServerError); - await expect(Retrieve(req, res)).rejects.toThrow("Failed to get the latest release from S3"); + await expect(Retrieve(req, res)).rejects.toThrow( + "Failed to get the latest release from S3", + ); }); }); @@ -768,165 +646,9 @@ describe("Retrieve handler", () => { expect(res2._json.appVersion).toBe("5.1.0"); // Still cached }); }); - - describe("new release auto-creation", () => { - beforeEach(async () => { - await resetToSeedData(); - }); - - it("should create new release with 10% rollout when version not in DB", async () => { - // Use a version that definitely doesn't exist in seed data - const newVersion = "9.9.9"; - - const req = createMockRequest({ deviceId: "new-release-device" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", newVersion]); - mockS3ListVersions("system", ["1.0.0", newVersion]); - mockS3HashFile("app", newVersion, "new-version-app-hash"); - mockS3HashFile("system", newVersion, "new-version-system-hash"); - - await Retrieve(req, res); - - // Verify the new release was created in DB with 10% rollout - const createdAppRelease = await testPrisma.release.findUnique({ - where: { version_type: { version: newVersion, type: "app" } }, - }); - const createdSystemRelease = await testPrisma.release.findUnique({ - where: { version_type: { version: newVersion, type: "system" } }, - }); - - expect(createdAppRelease).not.toBeNull(); - expect(createdAppRelease?.rolloutPercentage).toBe(10); - expect(createdSystemRelease).not.toBeNull(); - expect(createdSystemRelease?.rolloutPercentage).toBe(10); - - // Clean up - await testPrisma.release.deleteMany({ where: { version: newVersion } }); - }); - }); - - describe("default release selection", () => { - beforeEach(async () => { - await resetToSeedData(); - }); - - it("should return latest version among multiple 100% rollout releases", async () => { - // Explicitly set: 1.0.0 and 1.1.0 at 100%, 1.2.0 at 0% - await setRollout("1.0.0", "app", 100); - await setRollout("1.1.0", "app", 100); - await setRollout("1.2.0", "app", 0); - await setRollout("1.0.0", "system", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "system", 0); - - const req = createMockRequest({ deviceId: "default-selection-device" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req, res); - - // 1.2.0 has 0% rollout, so device gets 1.1.0 (latest 100% default) - expect(res._json.appVersion).toBe("1.1.0"); - expect(res._json.systemVersion).toBe("1.1.0"); - }); - }); - - describe("rollout eligibility", () => { - beforeEach(async () => { - await resetToSeedData(); - }); - - it("should be deterministic - same deviceId always gets same result", async () => { - // Set explicit rollout: 1.1.0 at 100%, 1.2.0 at 50% - await setRollout("1.1.0", "app", 100); - await setRollout("1.1.0", "system", 100); - await setRollout("1.2.0", "app", 50); - await setRollout("1.2.0", "system", 50); - - const deviceId = "deterministic-test-device-abc123"; - - // Make two separate calls with the same deviceId - const req1 = createMockRequest({ deviceId }); - const res1 = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req1, res1); - const firstAppVersion = res1._json.appVersion; - const firstSystemVersion = res1._json.systemVersion; - - // Clear caches and make second call - clearCaches(); - s3Mock.reset(); - - const req2 = createMockRequest({ deviceId }); - const res2 = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3ListVersions("system", ["1.0.0", "1.1.0", "1.2.0"]); - mockS3HashFile("app", "1.2.0", "abc123hash120"); - mockS3HashFile("system", "1.2.0", "sys123hash120"); - - await Retrieve(req2, res2); - - // Same deviceId should get same versions (deterministic) - expect(res2._json.appVersion).toBe(firstAppVersion); - expect(res2._json.systemVersion).toBe(firstSystemVersion); - }); - }); - - describe("response structure", () => { - it("should include all required fields in response", async () => { - const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["1.0.0"]); - mockS3ListVersions("system", ["1.0.0"]); - mockS3HashFile("app", "1.0.0", "app-hash"); - mockS3HashFile("system", "1.0.0", "system-hash"); - - await Retrieve(req, res); - - expect(res._json).toHaveProperty("appVersion"); - expect(res._json).toHaveProperty("appUrl"); - expect(res._json).toHaveProperty("appHash"); - expect(res._json).toHaveProperty("systemVersion"); - expect(res._json).toHaveProperty("systemUrl"); - expect(res._json).toHaveProperty("systemHash"); - }); - - it("should return correct URL format", async () => { - // Use unique version constraints for unique cache keys - const req = createMockRequest({ - deviceId: "device-url-test", - prerelease: "true", - appVersion: "^4.0.0", - systemVersion: "^4.0.0", - }); - const res = createMockResponse(); - - mockS3ListVersions("app", ["4.0.0"]); - mockS3ListVersions("system", ["4.0.0"]); - mockS3HashFile("app", "4.0.0", "app-hash-400"); - mockS3HashFile("system", "4.0.0", "system-hash-400"); - - await Retrieve(req, res); - - expect(res._json.appUrl).toBe("https://cdn.test.com/app/4.0.0/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/4.0.0/system.tar"); - }); - }); }); -describe("RetrieveLatestApp handler", () => { +describe("RetrieveLatestApp S3 redirect handler", () => { beforeEach(() => { s3Mock.reset(); clearCaches(); @@ -938,10 +660,7 @@ describe("RetrieveLatestApp handler", () => { // All versions are invalid semver s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ - CommonPrefixes: [ - { Prefix: "app/not-valid/" }, - { Prefix: "app/bad-version/" }, - ], + CommonPrefixes: [{ Prefix: "app/not-valid/" }, { Prefix: "app/bad-version/" }], }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); @@ -961,7 +680,11 @@ describe("RetrieveLatestApp handler", () => { const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ - CommonPrefixes: [{ Prefix: "app/1.0.0/" }, { Prefix: "app/1.1.0/" }, { Prefix: "app/1.2.0/" }], + CommonPrefixes: [ + { Prefix: "app/1.0.0/" }, + { Prefix: "app/1.1.0/" }, + { Prefix: "app/1.2.0/" }, + ], }); // Create content and matching hash @@ -973,7 +696,10 @@ describe("RetrieveLatestApp handler", () => { await RetrieveLatestApp(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.2.0/jetkvm_app"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/1.2.0/jetkvm_app", + ); }); it("should redirect to latest prerelease when prerelease=true", async () => { @@ -996,7 +722,10 @@ describe("RetrieveLatestApp handler", () => { await RetrieveLatestApp(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/2.0.0-beta.1/jetkvm_app"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/2.0.0-beta.1/jetkvm_app", + ); }); it("should throw InternalServerError when hash does not match", async () => { @@ -1007,7 +736,13 @@ describe("RetrieveLatestApp handler", () => { CommonPrefixes: [{ Prefix: "app/1.0.0/" }], }); - mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", "actual-content", "wrong-hash-value"); + mockS3LegacyVersionWithContent( + "app", + "1.0.0", + "jetkvm_app", + "actual-content", + "wrong-hash-value", + ); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(InternalServerError); }); @@ -1052,7 +787,10 @@ describe("RetrieveLatestApp handler", () => { await RetrieveLatestApp(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/1.0.0/jetkvm_app", + ); }); it("should use legacy path when default SKU provided on legacy version", async () => { @@ -1071,7 +809,10 @@ describe("RetrieveLatestApp handler", () => { await RetrieveLatestApp(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/app/1.0.0/jetkvm_app", + ); }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { @@ -1091,7 +832,7 @@ describe("RetrieveLatestApp handler", () => { await expect(RetrieveLatestApp(req, res)).rejects.toThrow("predates SKU support"); }); - it("should use SKU path when version has SKU support", async () => { + it("redirects to the requested SKU path when the S3 version has SKU support", async () => { const req = createMockRequest({ sku: "jetkvm-2" }); const res = createMockResponse(); @@ -1103,13 +844,20 @@ describe("RetrieveLatestApp handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash); + mockS3SkuVersionWithContent( + "app", + "2.0.0", + "jetkvm-2", + "jetkvm_app", + content, + hash, + ); await RetrieveLatestApp(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app" + "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app", ); }); @@ -1125,13 +873,20 @@ describe("RetrieveLatestApp handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-v2", "jetkvm_app", content, hash); + mockS3SkuVersionWithContent( + "app", + "2.0.0", + "jetkvm-v2", + "jetkvm_app", + content, + hash, + ); await RetrieveLatestApp(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app" + "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app", ); }); @@ -1147,13 +902,17 @@ describe("RetrieveLatestApp handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({ Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }], }); - s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({ - name: "NoSuchKey", - $metadata: { httpStatusCode: 404 }, - }); + s3Mock + .on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }) + .rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); - await expect(RetrieveLatestApp(req, res)).rejects.toThrow("is not available for version"); + await expect(RetrieveLatestApp(req, res)).rejects.toThrow( + "is not available for version", + ); }); }); @@ -1180,7 +939,13 @@ describe("RetrieveLatestApp handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ CommonPrefixes: [{ Prefix: "app/2.0.0/" }], }); - mockS3LegacyVersionWithContent("app", "2.0.0", "jetkvm_app", "new-content", "new-hash"); + mockS3LegacyVersionWithContent( + "app", + "2.0.0", + "jetkvm_app", + "new-content", + "new-hash", + ); // Second call should return cached result (1.0.0), not new S3 data (2.0.0) const req2 = createMockRequest({}); @@ -1213,18 +978,27 @@ describe("RetrieveLatestApp handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ CommonPrefixes: [{ Prefix: "app/2.0.0/" }], }); - mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash); + mockS3SkuVersionWithContent( + "app", + "2.0.0", + "jetkvm-2", + "jetkvm_app", + content, + hash, + ); const req2 = createMockRequest({ sku: "jetkvm-2" }); const res2 = createMockResponse(); await RetrieveLatestApp(req2, res2); - expect(res2._redirectUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app"); + expect(res2._redirectUrl).toBe( + "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app", + ); }); }); }); -describe("RetrieveLatestSystemRecovery handler", () => { +describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { beforeEach(() => { s3Mock.reset(); clearCaches(); @@ -1250,7 +1024,9 @@ describe("RetrieveLatestSystemRecovery handler", () => { const req = createMockRequest({}); const res = createMockResponse(); - s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ CommonPrefixes: [] }); + s3Mock + .on(ListObjectsV2Command, { Prefix: "system/" }) + .resolves({ CommonPrefixes: [] }); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); }); @@ -1275,7 +1051,10 @@ describe("RetrieveLatestSystemRecovery handler", () => { await RetrieveLatestSystemRecovery(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.2.0/update.img"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/system/1.2.0/update.img", + ); }); it("should redirect to latest prerelease when prerelease=true", async () => { @@ -1283,23 +1062,26 @@ describe("RetrieveLatestSystemRecovery handler", () => { const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ - CommonPrefixes: [ - { Prefix: "system/1.0.0/" }, - { Prefix: "system/2.0.0-alpha.1/" }, - ], + CommonPrefixes: [{ Prefix: "system/1.0.0/" }, { Prefix: "system/2.0.0-alpha.1/" }], }); const content = "system-prerelease-content"; const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3LegacyVersionWithContent("system", "2.0.0-alpha.1", "update.img", content, hash); + mockS3LegacyVersionWithContent( + "system", + "2.0.0-alpha.1", + "update.img", + content, + hash, + ); await RetrieveLatestSystemRecovery(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/system/2.0.0-alpha.1/update.img" + "https://cdn.test.com/system/2.0.0-alpha.1/update.img", ); }); @@ -1311,9 +1093,17 @@ describe("RetrieveLatestSystemRecovery handler", () => { CommonPrefixes: [{ Prefix: "system/1.0.0/" }], }); - mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", "actual-content", "mismatched-hash"); + mockS3LegacyVersionWithContent( + "system", + "1.0.0", + "update.img", + "actual-content", + "mismatched-hash", + ); - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(InternalServerError); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( + InternalServerError, + ); }); it("should throw NotFoundError when recovery image or hash file is missing", async () => { @@ -1356,7 +1146,10 @@ describe("RetrieveLatestSystemRecovery handler", () => { await RetrieveLatestSystemRecovery(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/system/1.0.0/update.img", + ); }); it("should use legacy path when default SKU provided on legacy version", async () => { @@ -1375,7 +1168,10 @@ describe("RetrieveLatestSystemRecovery handler", () => { await RetrieveLatestSystemRecovery(req, res); - expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img"); + expect(res.redirect).toHaveBeenCalledWith( + 302, + "https://cdn.test.com/system/1.0.0/update.img", + ); }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { @@ -1392,10 +1188,12 @@ describe("RetrieveLatestSystemRecovery handler", () => { }); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("predates SKU support"); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( + "predates SKU support", + ); }); - it("should use SKU path when version has SKU support", async () => { + it("redirects to the requested SKU path when the S3 version has SKU support", async () => { const req = createMockRequest({ sku: "jetkvm-2" }); const res = createMockResponse(); @@ -1407,13 +1205,20 @@ describe("RetrieveLatestSystemRecovery handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-2", "update.img", content, hash); + mockS3SkuVersionWithContent( + "system", + "2.0.0", + "jetkvm-2", + "update.img", + content, + hash, + ); await RetrieveLatestSystemRecovery(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img" + "https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img", ); }); @@ -1429,13 +1234,20 @@ describe("RetrieveLatestSystemRecovery handler", () => { const crypto = await import("crypto"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-v2", "update.img", content, hash); + mockS3SkuVersionWithContent( + "system", + "2.0.0", + "jetkvm-v2", + "update.img", + content, + hash, + ); await RetrieveLatestSystemRecovery(req, res); expect(res.redirect).toHaveBeenCalledWith( 302, - "https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img" + "https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img", ); }); @@ -1451,13 +1263,17 @@ describe("RetrieveLatestSystemRecovery handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({ Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/update.img" }], }); - s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({ - name: "NoSuchKey", - $metadata: { httpStatusCode: 404 }, - }); + s3Mock + .on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }) + .rejects({ + name: "NoSuchKey", + $metadata: { httpStatusCode: 404 }, + }); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("is not available for version"); + await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( + "is not available for version", + ); }); }); @@ -1484,7 +1300,13 @@ describe("RetrieveLatestSystemRecovery handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ CommonPrefixes: [{ Prefix: "system/2.0.0/" }], }); - mockS3LegacyVersionWithContent("system", "2.0.0", "update.img", "new-content", "new-hash"); + mockS3LegacyVersionWithContent( + "system", + "2.0.0", + "update.img", + "new-content", + "new-hash", + ); // Second call should return cached result (1.0.0), not new S3 data (2.0.0) const req2 = createMockRequest({}); @@ -1517,13 +1339,22 @@ describe("RetrieveLatestSystemRecovery handler", () => { s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ CommonPrefixes: [{ Prefix: "system/2.0.0/" }], }); - mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-2", "update.img", content, hash); + mockS3SkuVersionWithContent( + "system", + "2.0.0", + "jetkvm-2", + "update.img", + content, + hash, + ); const req2 = createMockRequest({ sku: "jetkvm-2" }); const res2 = createMockResponse(); await RetrieveLatestSystemRecovery(req2, res2); - expect(res2._redirectUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img"); + expect(res2._redirectUrl).toBe( + "https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img", + ); }); }); }); diff --git a/test/setup.ts b/test/setup.ts index 659c990..8f95d77 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -24,6 +24,12 @@ export const s3Mock = mockClient(S3Client); // Create a test Prisma client export const testPrisma = new PrismaClient(); +type ReleaseType = "app" | "system"; + +// Pre-SKU artifacts are jetkvm-v2 only; future SKUs need explicit +// skus// uploads, registered via scripts/sync-releases.ts. +const LEGACY_COMPATIBLE_SKUS = ["jetkvm-v2"]; + function ensureSafeTestDatabase() { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { @@ -45,7 +51,15 @@ function ensureSafeTestDatabase() { } // Seed data for releases -export const seedReleases = [ +interface SeedRelease { + version: string; + type: ReleaseType; + rolloutPercentage: number; + url: string; + hash: string; +} + +export const seedReleases: SeedRelease[] = [ // App releases { version: "1.0.0", @@ -92,9 +106,35 @@ export const seedReleases = [ }, ]; +function compatibleSkusForSeedRelease(_type: ReleaseType): string[] { + return LEGACY_COMPATIBLE_SKUS; +} + +type SeedReleaseArtifactSource = Pick; + +function seedReleaseArtifactData(releaseId: bigint, release: SeedReleaseArtifactSource) { + return { + releaseId, + url: release.url, + hash: release.hash, + compatibleSkus: compatibleSkusForSeedRelease(release.type), + }; +} + +async function createSeedRelease(release: SeedRelease): Promise { + const createdRelease = await testPrisma.release.create({ data: release }); + await testPrisma.releaseArtifact.create({ + data: seedReleaseArtifactData(createdRelease.id, release), + }); +} + // Helper to set rollout percentage for a specific version -export async function setRollout(version: string, type: "app" | "system", percentage: number) { - await testPrisma.release.upsert({ +export async function setRollout( + version: string, + type: ReleaseType, + percentage: number, +): Promise { + const release = await testPrisma.release.upsert({ where: { version_type: { version, type } }, update: { rolloutPercentage: percentage }, create: { @@ -105,6 +145,16 @@ export async function setRollout(version: string, type: "app" | "system", percen hash: `test-hash-${version}-${type}`, }, }); + + const artifactData = seedReleaseArtifactData(release.id, release); + await testPrisma.releaseArtifact.upsert({ + where: { releaseId_url: { releaseId: release.id, url: release.url } }, + update: { + hash: artifactData.hash, + compatibleSkus: artifactData.compatibleSkus, + }, + create: artifactData, + }); } // Helper to reset all releases to seed data baseline @@ -124,11 +174,16 @@ export async function resetToSeedData() { // Reset seed releases to original values for (const release of seedReleases) { - await testPrisma.release.upsert({ + const dbRelease = await testPrisma.release.upsert({ where: { version_type: { version: release.version, type: release.type } }, update: { rolloutPercentage: release.rolloutPercentage, url: release.url, hash: release.hash }, create: release, }); + + await testPrisma.releaseArtifact.deleteMany({ where: { releaseId: dbRelease.id } }); + await testPrisma.releaseArtifact.create({ + data: seedReleaseArtifactData(dbRelease.id, release), + }); } } @@ -159,11 +214,12 @@ beforeAll(async () => { await testPrisma.$connect(); // Clean up existing releases + await testPrisma.releaseArtifact.deleteMany({}); await testPrisma.release.deleteMany({}); // Seed the database with test releases for (const release of seedReleases) { - await testPrisma.release.create({ data: release }); + await createSeedRelease(release); } }); @@ -176,6 +232,7 @@ afterEach(() => { afterAll(async () => { // Clean up after all tests + await testPrisma.releaseArtifact.deleteMany({}); await testPrisma.release.deleteMany({}); await testPrisma.$disconnect(); }); diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts new file mode 100644 index 0000000..68267e1 --- /dev/null +++ b/test/sync-releases.test.ts @@ -0,0 +1,177 @@ +import { + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + S3Client, +} from "@aws-sdk/client-s3"; +import { describe, expect, beforeEach, it } from "vitest"; + +import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases"; +import { createAsyncIterable, s3Mock, testPrisma } from "./setup"; + +const DEFAULT_SKU = "jetkvm-v2"; +const SDMMC_SKU = "jetkvm-v2-sdmmc"; +const SYNC_BUCKET = "test-bucket"; +const SYNC_BASE_URL = "https://cdn.test.com"; +const syncS3Client = new S3Client({}); + +function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({ + CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })), + }); +} + +function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) { + const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [], + }); + s3Mock + .on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }) + .resolves({ + Body: createAsyncIterable(hash) as any, + }); +} + +function mockS3SkuVersion( + prefix: "app" | "system", + version: string, + sku: string, + hash: string, +) { + const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; + + s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ + Contents: [{ Key: skuPath }], + }); + s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({}); + s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({ + Body: createAsyncIterable(hash) as any, + }); +} + +describe("sync-releases script", () => { + beforeEach(() => { + s3Mock.reset(); + s3Mock + .on(HeadObjectCommand) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + }); + + it("marks legacy app artifacts compatible with the default SKU only", async () => { + mockS3HashFile("app", "9.9.1", "legacy-app-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "app", + "9.9.1", + ); + + expect(artifacts).toEqual([ + { + url: "https://cdn.test.com/app/9.9.1/jetkvm_app", + hash: "legacy-app-hash", + compatibleSkus: [DEFAULT_SKU], + }, + ]); + }); + + it("marks legacy system artifacts compatible with only the default SKU", async () => { + mockS3HashFile("system", "9.9.2", "legacy-system-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "system", + "9.9.2", + ); + + expect(artifacts).toEqual([ + { + url: "https://cdn.test.com/system/9.9.2/system.tar", + hash: "legacy-system-hash", + compatibleSkus: [DEFAULT_SKU], + }, + ]); + }); + + it("collects only SKU artifacts that exist and have a hash", async () => { + mockS3SkuVersion("system", "9.9.3", DEFAULT_SKU, "system-default-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "system", + "9.9.3", + ); + + expect(artifacts).toEqual([ + { + url: `https://cdn.test.com/system/9.9.3/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-default-hash", + compatibleSkus: [DEFAULT_SKU], + }, + ]); + }); + + it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { + const version = "9.9.4"; + + // Pre-existing system row simulates a release the migration (or a prior + // sync) already wrote. Sync must leave it completely untouched. + await testPrisma.release.create({ + data: { + version, + type: "system", + rolloutPercentage: 77, + url: "https://cdn.test.com/old-system.tar", + hash: "old-system-hash", + }, + }); + + mockS3ListVersions("app", [version, "10.0.0-beta.1"]); + mockS3ListVersions("system", [version]); + mockS3HashFile("app", version, "app-hash"); + mockS3SkuVersion("system", version, DEFAULT_SKU, "system-hash-v2"); + mockS3SkuVersion("system", version, SDMMC_SKU, "system-hash-sdmmc"); + + await syncReleases( + { prisma: testPrisma, s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + ); + + const appRelease = await testPrisma.release.findUniqueOrThrow({ + where: { version_type: { version, type: "app" } }, + include: { artifacts: true }, + }); + const systemRelease = await testPrisma.release.findUniqueOrThrow({ + where: { version_type: { version, type: "system" } }, + include: { artifacts: true }, + }); + const prerelease = await testPrisma.release.findUnique({ + where: { version_type: { version: "10.0.0-beta.1", type: "app" } }, + }); + + // App release is new — created at 10% rollout with a single legacy-compatible artifact. + expect(appRelease.rolloutPercentage).toBe(10); + expect(appRelease.artifacts).toEqual([ + expect.objectContaining({ + url: `https://cdn.test.com/app/${version}/jetkvm_app`, + hash: "app-hash", + compatibleSkus: [DEFAULT_SKU], + }), + ]); + + // System release already existed — sync must not touch rollout, URL, hash, + // or attach any new artifacts (those are handled by one-off scripts). + expect(systemRelease.rolloutPercentage).toBe(77); + expect(systemRelease.url).toBe("https://cdn.test.com/old-system.tar"); + expect(systemRelease.hash).toBe("old-system-hash"); + expect(systemRelease.artifacts).toEqual([]); + + // Prereleases are filtered out by listStableVersions. + expect(prerelease).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 3c8bbf1..904661e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ testTimeout: 30000, hookTimeout: 30000, include: ["test/**/*.test.ts"], - silent: "passed-only" + silent: "passed-only", + fileParallelism: false, }, }); From b9a1298fdbd76316eb495dedd5e95c94471bf5ca Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 19:21:48 +0200 Subject: [PATCH 2/4] test: accept both SKU-incompat 404 messages in compare-releases The release API used to emit "Version X predates SKU support..." for every SKU-incompat 404. Upcoming changes route this through getDefaultRelease and emit "No default release available for SKU..." instead. Both 404s mean the same thing to the device, so accept either wording in the diff tolerator. --- scripts/compare-releases.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh index bb2b95c..d4dc7f1 100755 --- a/scripts/compare-releases.sh +++ b/scripts/compare-releases.sh @@ -254,17 +254,24 @@ right_keys = set(right.keys()) if left_keys != {"name", "message"} or right_keys != {"name", "message"}: raise SystemExit(1) -version_pattern = re.compile( - r'^(Version )(.+?)( predates SKU support and cannot serve SKU "[^"]+")$' -) +# Both messages mean "no release compatible with this SKU is available"; +# the wording differs across deploys but the device sees the same 404. +no_compat_patterns = [ + re.compile(r'^Version .+ predates SKU support and cannot serve SKU "([^"]+)"$'), + re.compile(r'^No default (?:app|system) release available for SKU "([^"]+)"$'), +] + +def canonicalize(message): + for pattern in no_compat_patterns: + match = pattern.match(message) + if match: + return f'' + return message left_message = left.get("message", "") right_message = right.get("message", "") -left_normalized = version_pattern.sub(r"\1\3", left_message) -right_normalized = version_pattern.sub(r"\1\3", right_message) - -if left_normalized == right_normalized: +if canonicalize(left_message) == canonicalize(right_message): raise SystemExit(0) raise SystemExit(1) From 68b10004b5b0e03eb2790ac11b14e48fe1871aae Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 19:44:52 +0200 Subject: [PATCH 3/4] test: cover rollout-bucket fallback for SKU-incompat upgrades Adds a regression test for the rollout-aware path: when the in-progress release has no compatible artifact for the requested SKU and the device is outside the rollout window, the response must keep the default release instead of throwing. Also extends compare-releases.sh with a second device ID picked to land in a low rollout bucket (bucket 9 vs the existing bucket 81), so the script now exercises both eligible and ineligible rollout paths. --- scripts/compare-releases.sh | 6 +++++- test/releases.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh index d4dc7f1..8bab1e6 100755 --- a/scripts/compare-releases.sh +++ b/scripts/compare-releases.sh @@ -5,7 +5,11 @@ set -uo pipefail LOCAL_BASE="${LOCAL_BASE:-http://localhost:3000}" PROD_BASE="${PROD_BASE:-https://api.jetkvm.com}" -DEFAULT_DEVICE_IDS=("compare-device-1") +# Two device IDs picked so they straddle a typical staged rollout window: +# compare-device-1 → rollout bucket 81 (skips releases < 81%) +# compare-device-2 → rollout bucket 9 (catches releases >= 10%) +# Together they exercise both eligible and ineligible rollout paths. +DEFAULT_DEVICE_IDS=("compare-device-1" "compare-device-2") DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc") TRISTATE_VALUES=("__omit__" "false" "true") diff --git a/test/releases.test.ts b/test/releases.test.ts index 723c15b..b7fcfea 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -454,6 +454,41 @@ describe("Retrieve handler", () => { }); }); + it("keeps the default when an in-rollout release has no compatible artifact", async () => { + await createDbRelease("app", "3.2.5", 100, [ + { + ...releaseArtifact("app", "3.2.5", DEFAULT_SKU), + compatibleSkus: [DEFAULT_SKU, SDMMC_SKU], + }, + ]); + await createDbRelease("app", "3.2.6", 10, [ + releaseArtifact("app", "3.2.6", DEFAULT_SKU), + ]); + await createDbRelease("system", "3.2.5", 100, [ + releaseArtifact("system", "3.2.5", DEFAULT_SKU, "system-default-hash"), + releaseArtifact("system", "3.2.5", SDMMC_SKU, "system-sdmmc-hash"), + ]); + + // sdmmc-fallback-device hashes to bucket 33 — outside the 10% rollout + // window, so the request must not even attempt to upgrade onto 3.2.6. + const res = createMockResponse(); + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-fallback-device", + sku: SDMMC_SKU, + }), + res, + ); + + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.2.5", + appUrl: artifactUrl("app", "3.2.5"), + systemVersion: "3.2.5", + systemUrl: artifactUrl("system", "3.2.5", SDMMC_SKU), + systemHash: "system-sdmmc-hash", + }); + }); + it("does not fall back when the latest release lacks a compatible artifact", async () => { await createDbRelease("app", "3.3.0", 100, [ { From eebf332bc0b9d077003614bebcce76c73efb350d Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 20:01:56 +0200 Subject: [PATCH 4/4] fix: keep default release when latest has no compatible SKU artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the getDefaultRelease SKU-compat fix, the default path was graceful but the rollout-upgrade path stayed strict: if a device was in the rollout bucket and the latest release lacked a compatible artifact for the requested SKU, dbReleaseToMetadata still threw and 404'd the whole request — even though responseJson already held a valid default. Short-circuit the upgrade when the latest release has no compatible artifact and update the regression test to assert the default is kept instead of asserting the throw. --- src/releases.ts | 14 +++++++++----- test/releases.test.ts | 31 ++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index be53836..1985b6c 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -617,26 +617,30 @@ export async function Retrieve(req: Request, res: Response) { // Background update checks follow rollout percentages so new releases roll // out gradually. Devices outside the bucket fall back to the default (the - // newest 100%-rolled-out release). + // newest 100%-rolled-out release). If the latest release lacks a compatible + // artifact for this SKU (e.g. a SKU-specific build hasn't shipped yet) we + // silently keep the default rather than 404 the whole request. const responseJson = toRelease( dbReleaseToMetadata(defaultAppRelease, query.sku), dbReleaseToMetadata(defaultSystemRelease, query.sku), ); if ( - await isDeviceEligibleForLatestRelease( + latestAppRelease.artifacts.length > 0 && + (await isDeviceEligibleForLatestRelease( latestAppRelease.rolloutPercentage, query.deviceId, - ) + )) ) { setAppRelease(responseJson, dbReleaseToMetadata(latestAppRelease, query.sku)); } if ( - await isDeviceEligibleForLatestRelease( + latestSystemRelease.artifacts.length > 0 && + (await isDeviceEligibleForLatestRelease( latestSystemRelease.rolloutPercentage, query.deviceId, - ) + )) ) { setSystemRelease(responseJson, dbReleaseToMetadata(latestSystemRelease, query.sku)); } diff --git a/test/releases.test.ts b/test/releases.test.ts index b7fcfea..f13c9d0 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -489,7 +489,7 @@ describe("Retrieve handler", () => { }); }); - it("does not fall back when the latest release lacks a compatible artifact", async () => { + it("keeps the default when the latest release lacks a compatible artifact for an in-bucket device", async () => { await createDbRelease("app", "3.3.0", 100, [ { ...releaseArtifact("app", "3.3.0", DEFAULT_SKU), @@ -506,19 +506,28 @@ describe("Retrieve handler", () => { releaseArtifact("system", "3.3.0", DEFAULT_SKU, "system-default-hash"), releaseArtifact("system", "3.3.0", SDMMC_SKU, "system-sdmmc-hash"), ]); + // system 3.3.1 ships only with the default-SKU artifact — no sdmmc binary. await createDbRelease("system", "3.3.1", 100); - await expect( - Retrieve( - createMockRequest({ - deviceId: "sdmmc-compatible-fallback-device", - sku: SDMMC_SKU, - }), - createMockResponse(), - ), - ).rejects.toThrow( - 'Version 3.3.1 predates SKU support and cannot serve SKU "jetkvm-v2-sdmmc"', + // Every device is in-bucket at 100% rollout, so this exercises the + // upgrade path. The request must keep the default 3.3.0 system release + // rather than 404 because 3.3.1 has no sdmmc binary. + const res = createMockResponse(); + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-compatible-fallback-device", + sku: SDMMC_SKU, + }), + res, ); + + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.3.1", + appUrl: artifactUrl("app", "3.3.1"), + systemVersion: "3.3.0", + systemUrl: artifactUrl("system", "3.3.0", SDMMC_SKU), + systemHash: "system-sdmmc-hash", + }); }); it("does not discover or create stable releases from S3", async () => {