From c9e9f92840fce3d1f9f9cf7d3c1aec6420feae22 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 16:21:55 +0200 Subject: [PATCH 1/6] 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. --- package.json | 1 + .../migration.sql | 28 + prisma/schema.prisma | 22 +- scripts/compare-releases.sh | 760 ++++++++++++++++++ scripts/seed.ts | 35 +- scripts/sync-releases.ts | 275 +++++++ src/releases.ts | 205 +++-- test/releases.test.ts | 505 +++++++----- test/setup.ts | 66 +- 9 files changed, 1646 insertions(+), 251 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 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..1e95ef2 --- /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. +INSERT INTO "ReleaseArtifact" ("releaseId", "url", "hash", "compatibleSkus") +SELECT + "id", + "url", + "hash", + CASE + WHEN "type" = 'app' THEN ARRAY['jetkvm-v2', 'jetkvm-v2-sdmmc']::TEXT[] + ELSE ARRAY['jetkvm-v2']::TEXT[] + END +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..99badfd --- /dev/null +++ b/scripts/compare-releases.sh @@ -0,0 +1,760 @@ +#!/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-2" "jetkvm-3") +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[@]} * ${#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 force_update 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 6 + fi + query_keys=("deviceId" "prerelease" "forceUpdate" "appVersion" "systemVersion" "sku") + query_values=("$device_id" "$prerelease" "$force_update" "$app_version" "$system_version" "$sku") + run_case \ + "GET /releases deviceId=$device_id prerelease=$prerelease forceUpdate=$force_update appVersion=$app_version systemVersion=$system_version sku=$sku" \ + "/releases" \ + query_keys \ + query_values + done + 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..6215991 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -2,6 +2,15 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); +type ReleaseType = "app" | "system"; + +const APP_COMPATIBLE_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; +const SYSTEM_COMPATIBLE_SKUS = ["jetkvm-v2"]; + +function compatibleSkusForRelease(type: ReleaseType): string[] { + return type === "app" ? APP_COMPATIBLE_SKUS : SYSTEM_COMPATIBLE_SKUS; +} + // Development test users const users = [ { googleId: "dev-user-1", email: "dev@example.com", picture: null }, @@ -23,7 +32,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 +172,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..7c9f9a6 --- /dev/null +++ b/scripts/sync-releases.ts @@ -0,0 +1,275 @@ +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"; +} + +function legacyCompatibleSkus(type: ReleaseType, skus: string[]): string[] { + return type === "app" ? skus : [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(type, skus), + }, + ]; + } + + 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; + } + + const primaryArtifact = artifacts[0]; + const release = await prisma.release.upsert({ + where: { version_type: { version, type } }, + update: { + url: primaryArtifact.url, + hash: primaryArtifact.hash, + }, + create: { + version, + type, + rolloutPercentage: 10, + url: primaryArtifact.url, + hash: primaryArtifact.hash, + }, + }); + + for (const artifact of artifacts) { + await prisma.releaseArtifact.upsert({ + where: { releaseId_url: { releaseId: release.id, url: artifact.url } }, + update: { + hash: artifact.hash, + compatibleSkus: artifact.compatibleSkus, + }, + create: { + releaseId: release.id, + url: artifact.url, + hash: artifact.hash, + compatibleSkus: artifact.compatibleSkus, + }, + }); + } + + console.log( + `[sync-releases] ${type} ${version}: synced ${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..0056ac0 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 = () => @@ -87,6 +88,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 +389,10 @@ function toRelease( return release as Release; } +function addStableSigUrls(release: Release): void { + if (release.appUrl) release.appSigUrl = `${release.appUrl}.sig`; +} + async function getReleaseFromS3( includePrerelease: boolean, { @@ -403,10 +417,48 @@ 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, + type: ReleaseType, + 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 }, + select: compatibleReleaseSelect(sku), }); if (rolledOutReleases.length === 0) { @@ -429,6 +481,41 @@ async function getDefaultRelease(type: "app" | "system") { 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}`); + } + + 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,59 +523,49 @@ 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 }, - }); + // Version-constrained stable requests skip rollout but still read DB metadata. + if (skipRollout) { + const responseJson = toRelease( + dbReleaseToMetadata( + await getReleaseByRange("app", query.sku, appVersion), + "app", + query.sku, + appVersion, + ), + dbReleaseToMetadata( + await getReleaseByRange("system", query.sku, systemVersion), + "system", + query.sku, + systemVersion, + ), + ); + addStableSigUrls(responseJson); + return res.json(responseJson); + } - // 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 }, - }); + const latestAppRelease = await getLatestRelease("app", query.sku); + const latestSystemRelease = await getLatestRelease("system", query.sku); /* Return the latest release if forceUpdate is true, bypassing rollout rules. @@ -497,12 +574,21 @@ export async function Retrieve(req: Request, res: Response) { */ let responseJson: Release; if (query.forceUpdate) { - responseJson = toRelease(latestAppRelease, latestSystemRelease); + responseJson = toRelease( + dbReleaseToMetadata(latestAppRelease, "app", query.sku), + dbReleaseToMetadata(latestSystemRelease, "system", query.sku), + ); } else { - const defaultAppRelease = await getDefaultRelease("app"); - const defaultSystemRelease = await getDefaultRelease("system"); + const defaultAppRelease = await getDefaultRelease("app", query.sku); + const defaultSystemRelease = await getDefaultRelease("system", query.sku); + const defaultSystemMetadata = dbReleaseToMetadata( + defaultSystemRelease, + "system", + query.sku, + ); + const defaultAppMetadata = dbReleaseToMetadata(defaultAppRelease, "app", query.sku); - responseJson = toRelease(defaultAppRelease, defaultSystemRelease); + responseJson = toRelease(defaultAppMetadata, defaultSystemMetadata); if ( await isDeviceEligibleForLatestRelease( @@ -510,7 +596,10 @@ export async function Retrieve(req: Request, res: Response) { query.deviceId, ) ) { - setAppRelease(responseJson, latestAppRelease); + setAppRelease( + responseJson, + dbReleaseToMetadata(latestAppRelease, "app", query.sku), + ); } if ( @@ -519,13 +608,15 @@ export async function Retrieve(req: Request, res: Response) { query.deviceId, ) ) { - setSystemRelease(responseJson, latestSystemRelease); + setSystemRelease( + responseJson, + dbReleaseToMetadata(latestSystemRelease, "system", 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); + // Stable responses are DB-backed; signatures live next to the selected artifacts. + addStableSigUrls(responseJson); return res.json(responseJson); } diff --git a/test/releases.test.ts b/test/releases.test.ts index 85eb617..3e9b073 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1,7 +1,7 @@ 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, S3Client } from "@aws-sdk/client-s3"; +import { s3Mock, createAsyncIterable, testPrisma, setRollout, resetToSeedData } from "./setup"; import { BadRequestError, NotFoundError, InternalServerError } from "../src/errors"; // Import the module under test after setup @@ -12,6 +12,13 @@ import { clearCaches, } from "../src/releases"; import { getDeviceRolloutBucket } from "../src/helpers"; +import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases"; + +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({}); // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -170,6 +177,109 @@ function findDeviceIdInsideRollout(threshold: number) { throw new Error("Failed to find deviceId inside rollout bucket"); } +describe("sync-releases script", () => { + beforeEach(() => { + s3Mock.reset(); + s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + }); + + it("marks legacy app artifacts compatible with both known SKUs", 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, SDMMC_SKU], + }, + ]); + }); + + it("marks legacy system artifacts compatible with the default SKU only", 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("syncs S3 artifacts into DB without changing existing rollout", async () => { + const version = "9.9.3"; + + 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: { orderBy: { url: "asc" } } }, + }); + + 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, SDMMC_SKU], + }), + ]); + expect(systemRelease.rolloutPercentage).toBe(77); + expect(systemRelease.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-hash-v2", + compatibleSkus: [DEFAULT_SKU], + }), + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + hash: "system-hash-sdmmc", + compatibleSkus: [SDMMC_SKU], + }), + ]), + ); + }); +}); + describe("Retrieve handler", () => { beforeEach(() => { s3Mock.reset(); @@ -199,7 +309,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,7 +319,7 @@ 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 @@ -271,30 +381,22 @@ describe("Retrieve handler", () => { 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 + expect(res._json.appVersion).toBe("1.2.0"); // Max DB version satisfying ^1.0.0 + expect(res._json.systemVersion).toBe("1.2.0"); // No constraint, get latest DB version + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); 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 + expect(res._json.appVersion).toBe("1.2.0"); + expect(res._json.systemVersion).toBe("1.0.0"); // Max DB version satisfying ~1.0.0 + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); it("should skip rollout when version constraints are specified", async () => { @@ -305,10 +407,6 @@ describe("Retrieve handler", () => { }); 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); @@ -317,20 +415,20 @@ describe("Retrieve handler", () => { // Should return specified version directly (skipRollout=true) expect(res._json.appVersion).toBe("1.0.0"); expect(res._json.systemVersion).toBe("1.0.0"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); 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"]); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); }); describe("SKU handling", () => { - it("should use legacy path when no SKU provided on legacy version", async () => { + it("should use DB artifact when no SKU provided on pinned version", async () => { // Pin versions to bypass rollout; SKU behavior is the only variable here. const req = createMockRequest({ deviceId: "device-123", @@ -339,19 +437,15 @@ describe("Retrieve handler", () => { }); 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"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); - it("should use legacy path when default SKU provided on legacy version", async () => { + it("should use DB artifact when default SKU provided on pinned version", async () => { // Pin versions to bypass rollout; SKU behavior is the only variable here. const req = createMockRequest({ deviceId: "device-123", @@ -361,108 +455,77 @@ describe("Retrieve handler", () => { }); 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"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); - it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { + it("should throw when pinned version has no compatible system artifact", async () => { // Pin versions to bypass rollout; SKU behavior is the only variable here. const req = createMockRequest({ deviceId: "device-123", - sku: "jetkvm-2", + sku: SDMMC_SKU, 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-3"); - mockS3HashFile("system", "1.0.0", "legacy-system-hash-3"); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow("predates SKU support"); + await expect(Retrieve(req, res)).rejects.toThrow( + `Version 1.0.0 predates SKU support and cannot serve SKU "${SDMMC_SKU}"`, + ); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); - 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("should use compatible SKU artifact from DB on pinned version", async () => { + const systemRelease = await testPrisma.release.findUniqueOrThrow({ + where: { version_type: { version: "1.0.0", type: "system" } }, + }); + await testPrisma.releaseArtifact.create({ + data: { + releaseId: systemRelease.id, + url: `https://cdn.test.com/system/1.0.0/skus/${SDMMC_SKU}/system.tar`, + hash: "system-sdmmc-hash-100", + compatibleSkus: [SDMMC_SKU], + }, }); - 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(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"); - }); - 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", + sku: SDMMC_SKU, + appVersion: "1.0.0", + systemVersion: "1.0.0", }); 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(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-v2/jetkvm_app"); - expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/system.tar"); + 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/skus/${SDMMC_SKU}/system.tar`, + ); + expect(res._json.systemHash).toBe("system-sdmmc-hash-100"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); - it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { + it("should throw when requested SKU has no compatible DB artifact", async () => { const req = createMockRequest({ deviceId: "device-123", sku: "jetkvm-3", - appVersion: "^2.0.0", - systemVersion: "^2.0.0", + appVersion: "1.0.0", + systemVersion: "1.0.0", }); const res = createMockResponse(); - mockS3ListVersions("app", ["2.0.0"]); - mockS3ListVersions("system", ["2.0.0"]); - - // 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 }, - }); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow("is not available for version"); + await expect(Retrieve(req, res)).rejects.toThrow( + 'Version 1.0.0 predates SKU support and cannot serve SKU "jetkvm-3"', + ); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); }); @@ -510,6 +573,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", @@ -530,25 +594,17 @@ describe("Retrieve handler", () => { 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"); + expect(res._json.appVersion).toBe("1.2.0"); + expect(res._json.systemVersion).toBe("1.2.0"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); }); it("should include sigUrl when forceUpdate=true and .sig file exists", async () => { @@ -558,16 +614,11 @@ describe("Retrieve handler", () => { }); 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"); + 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).toBeUndefined(); }); }); @@ -589,11 +640,6 @@ describe("Retrieve handler", () => { 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) @@ -612,11 +658,6 @@ describe("Retrieve handler", () => { 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 @@ -634,11 +675,6 @@ describe("Retrieve handler", () => { 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) @@ -656,11 +692,6 @@ describe("Retrieve handler", () => { 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%) @@ -678,16 +709,11 @@ describe("Retrieve handler", () => { 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"); + expect(res._json.systemSigUrl).toBeUndefined(); }); }); @@ -708,18 +734,13 @@ describe("Retrieve handler", () => { 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); }); }); 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) @@ -769,26 +790,36 @@ describe("Retrieve handler", () => { }); }); - describe("new release auto-creation", () => { + describe("stable DB-backed behavior", () => { 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 + async function addSdmmcSystemArtifact(version: string, hash = `system-sdmmc-hash-${version}`) { + const release = await testPrisma.release.findUniqueOrThrow({ + where: { version_type: { version, type: "system" } }, + }); + + await testPrisma.releaseArtifact.create({ + data: { + releaseId: release.id, + url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + hash, + compatibleSkus: [SDMMC_SKU], + }, + }); + } + + it("should not create releases or artifacts from S3 during stable wildcard requests", async () => { const newVersion = "9.9.9"; - const req = createMockRequest({ deviceId: "new-release-device" }); + const req = createMockRequest({ deviceId: "new-release-device", forceUpdate: "true" }); 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"); + s3Mock.on(ListObjectsV2Command).rejects(new Error("stable requests should not call S3")); 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" } }, }); @@ -796,13 +827,142 @@ describe("Retrieve handler", () => { 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); + expect(res._json.appVersion).toBe("1.2.0"); + expect(res._json.systemVersion).toBe("1.2.0"); + expect(createdAppRelease).toBeNull(); + expect(createdSystemRelease).toBeNull(); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + }); + + it("uses compatible SKU artifacts on the default rollout path without S3", async () => { + 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); + await addSdmmcSystemArtifact("1.1.0", "system-sdmmc-hash-110"); + await addSdmmcSystemArtifact("1.2.0", "system-sdmmc-hash-120"); + + const res = createMockResponse(); + + await Retrieve( + createMockRequest({ deviceId: "sdmmc-default-path-device", sku: SDMMC_SKU }), + res, + ); + + expect(res._json.appVersion).toBe("1.1.0"); + expect(res._json.systemVersion).toBe("1.1.0"); + expect(res._json.systemUrl).toBe( + `https://cdn.test.com/system/1.1.0/skus/${SDMMC_SKU}/system.tar`, + ); + expect(res._json.systemHash).toBe("system-sdmmc-hash-110"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); + }); + + it("uses compatible SKU artifacts on the forceUpdate path without S3", async () => { + await addSdmmcSystemArtifact("1.2.0", "system-sdmmc-hash-120"); + + const res = createMockResponse(); + + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-force-path-device", + sku: SDMMC_SKU, + forceUpdate: "true", + }), + res, + ); + + expect(res._json.appVersion).toBe("1.2.0"); + expect(res._json.systemVersion).toBe("1.2.0"); + expect(res._json.systemUrl).toBe( + `https://cdn.test.com/system/1.2.0/skus/${SDMMC_SKU}/system.tar`, + ); + expect(res._json.systemHash).toBe("system-sdmmc-hash-120"); + expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); + }); + + it("returns systemUrl/systemHash from the compatible artifact under one Release", async () => { + const version = "9.9.1"; + + await testPrisma.release.create({ + data: { + version, + type: "system", + rolloutPercentage: 100, + url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-hash-v2", + artifacts: { + create: [ + { + url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-hash-v2", + compatibleSkus: [DEFAULT_SKU], + }, + { + url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + hash: "system-hash-sdmmc", + compatibleSkus: [SDMMC_SKU], + }, + ], + }, + }, + }); + + const sdmmcResponse = createMockResponse(); + await Retrieve( + createMockRequest({ deviceId: "artifact-sdmmc-device", sku: SDMMC_SKU, forceUpdate: "true" }), + sdmmcResponse, + ); + + const systemRelease = await testPrisma.release.findUniqueOrThrow({ + where: { version_type: { version, type: "system" } }, + include: { artifacts: { orderBy: { url: "asc" } } }, + }); - // Clean up - await testPrisma.release.deleteMany({ where: { version: newVersion } }); + expect(systemRelease.rolloutPercentage).toBe(100); + expect(systemRelease.url).toBe(`https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`); + expect(systemRelease.hash).toBe("system-hash-v2"); + expect(systemRelease.artifacts).toHaveLength(2); + expect(systemRelease.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-hash-v2", + compatibleSkus: [DEFAULT_SKU], + }), + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + hash: "system-hash-sdmmc", + compatibleSkus: [SDMMC_SKU], + }), + ]), + ); + expect(sdmmcResponse._json.systemUrl).toBe( + `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + ); + expect(sdmmcResponse._json.systemHash).toBe("system-hash-sdmmc"); + }); + + it("fails when a DB-backed response has no compatible system artifact", async () => { + 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); + + await expect( + Retrieve( + createMockRequest({ deviceId: "missing-system-artifact-device", sku: SDMMC_SKU }), + createMockResponse(), + ), + ).rejects.toThrow(NotFoundError); + await expect( + Retrieve( + createMockRequest({ deviceId: "missing-system-artifact-device", sku: SDMMC_SKU }), + createMockResponse(), + ), + ).rejects.toThrow(`Version 1.1.0 predates SKU support and cannot serve SKU "${SDMMC_SKU}"`); }); }); @@ -823,11 +983,6 @@ describe("Retrieve handler", () => { 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) @@ -854,27 +1009,13 @@ describe("Retrieve handler", () => { 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) diff --git a/test/setup.ts b/test/setup.ts index 659c990..e4e8cce 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -24,6 +24,11 @@ export const s3Mock = mockClient(S3Client); // Create a test Prisma client export const testPrisma = new PrismaClient(); +type ReleaseType = "app" | "system"; + +const APP_COMPATIBLE_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; +const SYSTEM_COMPATIBLE_SKUS = ["jetkvm-v2"]; + function ensureSafeTestDatabase() { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { @@ -45,7 +50,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 +105,35 @@ export const seedReleases = [ }, ]; +function compatibleSkusForSeedRelease(type: ReleaseType): string[] { + return type === "app" ? APP_COMPATIBLE_SKUS : SYSTEM_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 +144,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 +173,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 +213,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 +231,7 @@ afterEach(() => { afterAll(async () => { // Clean up after all tests + await testPrisma.releaseArtifact.deleteMany({}); await testPrisma.release.deleteMany({}); await testPrisma.$disconnect(); }); From 9cd63a1acdc9580cce8fc7ea717a875684728b5a Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 16:39:13 +0200 Subject: [PATCH 2/6] 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. --- src/releases.ts | 22 +- test/releases.test.ts | 1135 +++++++++++++----------------------- test/sync-releases.test.ts | 180 ++++++ vitest.config.ts | 3 +- 4 files changed, 611 insertions(+), 729 deletions(-) create mode 100644 test/sync-releases.test.ts diff --git a/src/releases.ts b/src/releases.ts index 0056ac0..70cdfba 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -391,6 +391,7 @@ function toRelease( function addStableSigUrls(release: Release): void { if (release.appUrl) release.appSigUrl = `${release.appUrl}.sig`; + if (release.systemUrl) release.systemSigUrl = `${release.systemUrl}.sig`; } async function getReleaseFromS3( @@ -426,6 +427,13 @@ function compatibleArtifactSelect(sku: string) { }; } +function compatibleReleaseWhere(type: ReleaseType, sku: string) { + return { + type, + artifacts: { some: { compatibleSkus: { has: sku } } }, + } as const; +} + function compatibleReleaseSelect(sku: string) { return { version: true, @@ -457,12 +465,14 @@ function dbReleaseToMetadata( async function getDefaultRelease(type: ReleaseType, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ - where: { rolloutPercentage: 100, type }, + where: { ...compatibleReleaseWhere(type, sku), 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}"`, + ); } // Get the latest default version from the rolled out releases @@ -475,7 +485,9 @@ async function getDefaultRelease(type: ReleaseType, sku: string): Promise 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; @@ -491,12 +503,12 @@ async function getReleaseByRange( range: string, ): Promise { const releases = await prisma.release.findMany({ - where: { type }, + where: compatibleReleaseWhere(type, sku), select: compatibleReleaseSelect(sku), }); if (releases.length === 0) { - throw new NotFoundError(`No release found for type ${type}`); + throw new NotFoundError(`No release found for type ${type} and SKU "${sku}"`); } const latestVersion = semver.maxSatisfying( diff --git a/test/releases.test.ts b/test/releases.test.ts index 3e9b073..40f8758 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, S3Client } from "@aws-sdk/client-s3"; -import { s3Mock, createAsyncIterable, testPrisma, 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,14 +15,10 @@ import { RetrieveLatestSystemRecovery, clearCaches, } from "../src/releases"; -import { getDeviceRolloutBucket } from "../src/helpers"; -import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases"; 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({}); +type ReleaseType = "app" | "system"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -28,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: "", @@ -42,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}`; @@ -104,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({ @@ -122,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) @@ -134,7 +148,7 @@ function mockS3SkuVersionWithContent( sku: string, fileName: string, content: string, - hash: string + hash: string, ) { const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; @@ -157,135 +171,68 @@ 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; - } - } - throw new Error("Failed to find deviceId inside rollout bucket"); +function artifactUrl(type: ReleaseType, version: string, sku = DEFAULT_SKU) { + const fileName = artifactFileName(type); + const path = + sku === DEFAULT_SKU + ? `${type}/${version}/${fileName}` + : `${type}/${version}/skus/${sku}/${fileName}`; + return `https://cdn.test.com/${path}`; } -describe("sync-releases script", () => { - beforeEach(() => { - s3Mock.reset(); - s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); - }); - - it("marks legacy app artifacts compatible with both known SKUs", 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, SDMMC_SKU], - }, - ]); - }); - - it("marks legacy system artifacts compatible with the default SKU only", 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", - ); +function releaseArtifact( + type: ReleaseType, + version: string, + sku = DEFAULT_SKU, + hash = `${type}-${version}-${sku}-hash`, +) { + return { + url: artifactUrl(type, version, sku), + hash, + compatibleSkus: [sku], + }; +} - expect(artifacts).toEqual([ - { - url: "https://cdn.test.com/system/9.9.2/system.tar", - hash: "legacy-system-hash", - compatibleSkus: [DEFAULT_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 }, + }, }); +} - it("syncs S3 artifacts into DB without changing existing rollout", async () => { - const version = "9.9.3"; - - 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: { orderBy: { url: "asc" } } }, - }); +async function createDbReleasePair(version: string, rolloutPercentage: number) { + await createDbRelease("app", version, rolloutPercentage); + await createDbRelease("system", version, rolloutPercentage); +} - 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, SDMMC_SKU], - }), - ]); - expect(systemRelease.rolloutPercentage).toBe(77); - expect(systemRelease.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, - hash: "system-hash-v2", - compatibleSkus: [DEFAULT_SKU], - }), - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - hash: "system-hash-sdmmc", - compatibleSkus: [SDMMC_SKU], - }), - ]), - ); - }); -}); +function jsonBody(res: { _json: unknown }) { + return JSON.parse(JSON.stringify(res._json)); +} describe("Retrieve handler", () => { beforeEach(() => { 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(); }); @@ -324,10 +271,16 @@ describe("Retrieve handler", () => { // 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); @@ -376,156 +329,195 @@ 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(); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("1.2.0"); // Max DB version satisfying ^1.0.0 - expect(res._json.systemVersion).toBe("1.2.0"); // No constraint, get latest DB version - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + 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(); + it("serves the latest fully rolled out release on background checks", async () => { + await createDbReleasePair("2.0.0", 100); + await createDbReleasePair("2.1.0", 0); - await Retrieve(req, res); + const res = createMockResponse(); - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.0.0"); // Max DB version satisfying ~1.0.0 - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(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 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(); + it("serves the latest DB release when forceUpdate bypasses rollout", async () => { + await createDbReleasePair("2.2.0", 100); + await createDbReleasePair("2.3.0", 0); - await setRollout("1.0.0", "app", 0); - await setRollout("1.0.0", "system", 0); + const res = createMockResponse(); - await Retrieve(req, res); + await Retrieve( + createMockRequest({ deviceId: "manual-update-device", forceUpdate: "true" }), + res, + ); - // Should return specified version directly (skipRollout=true) - expect(res._json.appVersion).toBe("1.0.0"); - expect(res._json.systemVersion).toBe("1.0.0"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + expect(jsonBody(res)).toMatchObject({ + appVersion: "2.3.0", + appUrl: artifactUrl("app", "2.3.0"), + appSigUrl: `${artifactUrl("app", "2.3.0")}.sig`, + systemVersion: "2.3.0", + systemUrl: artifactUrl("system", "2.3.0"), + systemSigUrl: `${artifactUrl("system", "2.3.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(); + 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); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - }); - }); - - describe("SKU handling", () => { - it("should use DB artifact when no SKU provided on pinned 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(); - await Retrieve(req, res); + await Retrieve(createMockRequest({ deviceId: "split-rollout-device" }), 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"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(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"), + }); }); - it("should use DB artifact when default SKU provided on pinned 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", - }); + it("uses DB version ranges and bypasses rollout for constrained requests", async () => { + await createDbReleasePair("3.0.0", 100); + await createDbReleasePair("3.1.0", 0); + const res = createMockResponse(); - await Retrieve(req, res); + await Retrieve( + createMockRequest({ + deviceId: "pinned-device", + appVersion: "^3.0.0", + systemVersion: "3.0.0", + }), + 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"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + 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 throw when pinned version has no compatible system artifact", async () => { - // Pin versions to bypass rollout; SKU behavior is the only variable here. - const req = createMockRequest({ - deviceId: "device-123", - sku: SDMMC_SKU, - appVersion: "1.0.0", - systemVersion: "1.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(); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow( - `Version 1.0.0 predates SKU support and cannot serve SKU "${SDMMC_SKU}"`, + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-device", + sku: SDMMC_SKU, + forceUpdate: "true", + }), + res, ); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - }); - it("should use compatible SKU artifact from DB on pinned version", async () => { - const systemRelease = await testPrisma.release.findUniqueOrThrow({ - where: { version_type: { version: "1.0.0", type: "system" } }, + 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", }); - await testPrisma.releaseArtifact.create({ - data: { - releaseId: systemRelease.id, - url: `https://cdn.test.com/system/1.0.0/skus/${SDMMC_SKU}/system.tar`, - hash: "system-sdmmc-hash-100", - compatibleSkus: [SDMMC_SKU], + }); + + it("falls back to the latest release with 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); - const req = createMockRequest({ - deviceId: "device-123", - sku: SDMMC_SKU, - appVersion: "1.0.0", - systemVersion: "1.0.0", - }); const res = createMockResponse(); - 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/skus/${SDMMC_SKU}/system.tar`, + await Retrieve( + createMockRequest({ + deviceId: "sdmmc-compatible-fallback-device", + sku: SDMMC_SKU, + forceUpdate: "true", + }), + res, ); - expect(res._json.systemHash).toBe("system-sdmmc-hash-100"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - }); - it("should throw when requested SKU has no compatible DB artifact", async () => { - const req = createMockRequest({ - deviceId: "device-123", - sku: "jetkvm-3", - appVersion: "1.0.0", - systemVersion: "1.0.0", + expect(jsonBody(res)).toMatchObject({ + appVersion: "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 () => { + 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(); - await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError); - await expect(Retrieve(req, res)).rejects.toThrow( - 'Version 1.0.0 predates SKU support and cannot serve SKU "jetkvm-3"', + await Retrieve( + createMockRequest({ deviceId: "db-only-device", forceUpdate: "true" }), + res, ); + + expect(jsonBody(res)).toMatchObject({ + appVersion: "3.4.0", + systemVersion: "3.4.0", + }); expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); + expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); + }); + + 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); }); }); @@ -547,7 +539,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 () => { @@ -583,158 +577,18 @@ 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 () => { - const req = createMockRequest({ - deviceId: "device-force", - forceUpdate: "true", - }); - const res = createMockResponse(); - - await Retrieve(req, res); - - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.2.0"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - }); - - 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(); - - 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).toBeUndefined(); - }); - }); - - 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(); - - 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(); - - 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(); - - 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(); 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(); - - 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).toBeUndefined(); - }); - }); - - 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(); - - 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", + ); }); }); @@ -747,7 +601,9 @@ describe("Retrieve handler", () => { 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", + ); }); }); @@ -789,285 +645,9 @@ describe("Retrieve handler", () => { expect(res2._json.appVersion).toBe("5.1.0"); // Still cached }); }); - - describe("stable DB-backed behavior", () => { - beforeEach(async () => { - await resetToSeedData(); - }); - - async function addSdmmcSystemArtifact(version: string, hash = `system-sdmmc-hash-${version}`) { - const release = await testPrisma.release.findUniqueOrThrow({ - where: { version_type: { version, type: "system" } }, - }); - - await testPrisma.releaseArtifact.create({ - data: { - releaseId: release.id, - url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - hash, - compatibleSkus: [SDMMC_SKU], - }, - }); - } - - it("should not create releases or artifacts from S3 during stable wildcard requests", async () => { - const newVersion = "9.9.9"; - - const req = createMockRequest({ deviceId: "new-release-device", forceUpdate: "true" }); - const res = createMockResponse(); - - s3Mock.on(ListObjectsV2Command).rejects(new Error("stable requests should not call S3")); - - await Retrieve(req, res); - - 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(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.2.0"); - expect(createdAppRelease).toBeNull(); - expect(createdSystemRelease).toBeNull(); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - }); - - it("uses compatible SKU artifacts on the default rollout path without S3", async () => { - 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); - await addSdmmcSystemArtifact("1.1.0", "system-sdmmc-hash-110"); - await addSdmmcSystemArtifact("1.2.0", "system-sdmmc-hash-120"); - - const res = createMockResponse(); - - await Retrieve( - createMockRequest({ deviceId: "sdmmc-default-path-device", sku: SDMMC_SKU }), - res, - ); - - expect(res._json.appVersion).toBe("1.1.0"); - expect(res._json.systemVersion).toBe("1.1.0"); - expect(res._json.systemUrl).toBe( - `https://cdn.test.com/system/1.1.0/skus/${SDMMC_SKU}/system.tar`, - ); - expect(res._json.systemHash).toBe("system-sdmmc-hash-110"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); - }); - - it("uses compatible SKU artifacts on the forceUpdate path without S3", async () => { - await addSdmmcSystemArtifact("1.2.0", "system-sdmmc-hash-120"); - - const res = createMockResponse(); - - await Retrieve( - createMockRequest({ - deviceId: "sdmmc-force-path-device", - sku: SDMMC_SKU, - forceUpdate: "true", - }), - res, - ); - - expect(res._json.appVersion).toBe("1.2.0"); - expect(res._json.systemVersion).toBe("1.2.0"); - expect(res._json.systemUrl).toBe( - `https://cdn.test.com/system/1.2.0/skus/${SDMMC_SKU}/system.tar`, - ); - expect(res._json.systemHash).toBe("system-sdmmc-hash-120"); - expect(s3Mock.commandCalls(ListObjectsV2Command)).toHaveLength(0); - expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0); - }); - - it("returns systemUrl/systemHash from the compatible artifact under one Release", async () => { - const version = "9.9.1"; - - await testPrisma.release.create({ - data: { - version, - type: "system", - rolloutPercentage: 100, - url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, - hash: "system-hash-v2", - artifacts: { - create: [ - { - url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, - hash: "system-hash-v2", - compatibleSkus: [DEFAULT_SKU], - }, - { - url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - hash: "system-hash-sdmmc", - compatibleSkus: [SDMMC_SKU], - }, - ], - }, - }, - }); - - const sdmmcResponse = createMockResponse(); - await Retrieve( - createMockRequest({ deviceId: "artifact-sdmmc-device", sku: SDMMC_SKU, forceUpdate: "true" }), - sdmmcResponse, - ); - - const systemRelease = await testPrisma.release.findUniqueOrThrow({ - where: { version_type: { version, type: "system" } }, - include: { artifacts: { orderBy: { url: "asc" } } }, - }); - - expect(systemRelease.rolloutPercentage).toBe(100); - expect(systemRelease.url).toBe(`https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`); - expect(systemRelease.hash).toBe("system-hash-v2"); - expect(systemRelease.artifacts).toHaveLength(2); - expect(systemRelease.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, - hash: "system-hash-v2", - compatibleSkus: [DEFAULT_SKU], - }), - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - hash: "system-hash-sdmmc", - compatibleSkus: [SDMMC_SKU], - }), - ]), - ); - expect(sdmmcResponse._json.systemUrl).toBe( - `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - ); - expect(sdmmcResponse._json.systemHash).toBe("system-hash-sdmmc"); - }); - - it("fails when a DB-backed response has no compatible system artifact", async () => { - 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); - - await expect( - Retrieve( - createMockRequest({ deviceId: "missing-system-artifact-device", sku: SDMMC_SKU }), - createMockResponse(), - ), - ).rejects.toThrow(NotFoundError); - await expect( - Retrieve( - createMockRequest({ deviceId: "missing-system-artifact-device", sku: SDMMC_SKU }), - createMockResponse(), - ), - ).rejects.toThrow(`Version 1.1.0 predates SKU support and cannot serve SKU "${SDMMC_SKU}"`); - }); - }); - - 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(); - - 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(); - - await Retrieve(req1, res1); - const firstAppVersion = res1._json.appVersion; - const firstSystemVersion = res1._json.systemVersion; - - const req2 = createMockRequest({ deviceId }); - const res2 = createMockResponse(); - - 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(); @@ -1079,10 +659,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); @@ -1102,7 +679,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 @@ -1114,7 +695,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 () => { @@ -1137,7 +721,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 () => { @@ -1148,7 +735,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); }); @@ -1193,7 +786,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 () => { @@ -1212,7 +808,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 () => { @@ -1232,7 +831,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(); @@ -1244,13 +843,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", ); }); @@ -1266,13 +872,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", ); }); @@ -1288,13 +901,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", + ); }); }); @@ -1321,7 +938,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({}); @@ -1354,18 +977,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(); @@ -1391,7 +1023,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); }); @@ -1416,7 +1050,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 () => { @@ -1424,23 +1061,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", ); }); @@ -1452,9 +1092,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 () => { @@ -1497,7 +1145,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 () => { @@ -1516,7 +1167,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 () => { @@ -1533,10 +1187,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(); @@ -1548,13 +1204,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", ); }); @@ -1570,13 +1233,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", ); }); @@ -1592,13 +1262,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", + ); }); }); @@ -1625,7 +1299,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({}); @@ -1658,13 +1338,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/sync-releases.test.ts b/test/sync-releases.test.ts new file mode 100644 index 0000000..f006f6e --- /dev/null +++ b/test/sync-releases.test.ts @@ -0,0 +1,180 @@ +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 all known SKUs", 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, SDMMC_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("syncs stable S3 artifacts into DB without changing existing rollout", async () => { + const version = "9.9.4"; + + 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: { orderBy: { url: "asc" } } }, + }); + const prerelease = await testPrisma.release.findUnique({ + where: { version_type: { version: "10.0.0-beta.1", type: "app" } }, + }); + + 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, SDMMC_SKU], + }), + ]); + expect(systemRelease.rolloutPercentage).toBe(77); + expect(systemRelease.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, + hash: "system-hash-v2", + compatibleSkus: [DEFAULT_SKU], + }), + expect.objectContaining({ + url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, + hash: "system-hash-sdmmc", + compatibleSkus: [SDMMC_SKU], + }), + ]), + ); + 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 9aaa9e7c48ff9099f4880eebcc29f36c370c4f35 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 17:51:17 +0200 Subject: [PATCH 3/6] fix: match production OTA release responses Only expose stable signature URLs that actually exist and preserve production's version-first SKU error behavior. --- src/releases.ts | 73 +++++++++++++++++++++++++++---------------- test/releases.test.ts | 72 ++++++++++++++++++++++++++++-------------- 2 files changed, 95 insertions(+), 50 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 70cdfba..24b1222 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -389,9 +389,42 @@ function toRelease( return release as Release; } -function addStableSigUrls(release: Release): void { - if (release.appUrl) release.appSigUrl = `${release.appUrl}.sig`; - if (release.systemUrl) release.systemSigUrl = `${release.systemUrl}.sig`; +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( @@ -427,13 +460,6 @@ function compatibleArtifactSelect(sku: string) { }; } -function compatibleReleaseWhere(type: ReleaseType, sku: string) { - return { - type, - artifacts: { some: { compatibleSkus: { has: sku } } }, - } as const; -} - function compatibleReleaseSelect(sku: string) { return { version: true, @@ -444,7 +470,6 @@ function compatibleReleaseSelect(sku: string) { function dbReleaseToMetadata( release: DbRelease, - type: ReleaseType, sku: string, maxSatisfying?: string, ): ReleaseMetadata { @@ -465,7 +490,7 @@ function dbReleaseToMetadata( async function getDefaultRelease(type: ReleaseType, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ - where: { ...compatibleReleaseWhere(type, sku), rolloutPercentage: 100 }, + where: { type, rolloutPercentage: 100 }, select: compatibleReleaseSelect(sku), }); @@ -503,7 +528,7 @@ async function getReleaseByRange( range: string, ): Promise { const releases = await prisma.release.findMany({ - where: compatibleReleaseWhere(type, sku), + where: { type }, select: compatibleReleaseSelect(sku), }); @@ -561,18 +586,16 @@ export async function Retrieve(req: Request, res: Response) { const responseJson = toRelease( dbReleaseToMetadata( await getReleaseByRange("app", query.sku, appVersion), - "app", query.sku, appVersion, ), dbReleaseToMetadata( await getReleaseByRange("system", query.sku, systemVersion), - "system", query.sku, systemVersion, ), ); - addStableSigUrls(responseJson); + await addStableSigUrls(responseJson); return res.json(responseJson); } @@ -587,18 +610,14 @@ export async function Retrieve(req: Request, res: Response) { let responseJson: Release; if (query.forceUpdate) { responseJson = toRelease( - dbReleaseToMetadata(latestAppRelease, "app", query.sku), - dbReleaseToMetadata(latestSystemRelease, "system", query.sku), + dbReleaseToMetadata(latestAppRelease, query.sku), + dbReleaseToMetadata(latestSystemRelease, query.sku), ); } else { const defaultAppRelease = await getDefaultRelease("app", query.sku); const defaultSystemRelease = await getDefaultRelease("system", query.sku); - const defaultSystemMetadata = dbReleaseToMetadata( - defaultSystemRelease, - "system", - query.sku, - ); - const defaultAppMetadata = dbReleaseToMetadata(defaultAppRelease, "app", query.sku); + const defaultSystemMetadata = dbReleaseToMetadata(defaultSystemRelease, query.sku); + const defaultAppMetadata = dbReleaseToMetadata(defaultAppRelease, query.sku); responseJson = toRelease(defaultAppMetadata, defaultSystemMetadata); @@ -610,7 +629,7 @@ export async function Retrieve(req: Request, res: Response) { ) { setAppRelease( responseJson, - dbReleaseToMetadata(latestAppRelease, "app", query.sku), + dbReleaseToMetadata(latestAppRelease, query.sku), ); } @@ -622,13 +641,13 @@ export async function Retrieve(req: Request, res: Response) { ) { setSystemRelease( responseJson, - dbReleaseToMetadata(latestSystemRelease, "system", query.sku), + dbReleaseToMetadata(latestSystemRelease, query.sku), ); } } // Stable responses are DB-backed; signatures live next to the selected artifacts. - addStableSigUrls(responseJson); + await addStableSigUrls(responseJson); return res.json(responseJson); } diff --git a/test/releases.test.ts b/test/releases.test.ts index 40f8758..ac7fb4b 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -175,13 +175,22 @@ function artifactFileName(type: ReleaseType) { return type === "app" ? "jetkvm_app" : "system.tar"; } -function artifactUrl(type: ReleaseType, version: string, sku = DEFAULT_SKU) { +function artifactPath(type: ReleaseType, version: string, sku = DEFAULT_SKU) { const fileName = artifactFileName(type); - const path = - sku === DEFAULT_SKU - ? `${type}/${version}/${fileName}` - : `${type}/${version}/skus/${sku}/${fileName}`; - return `https://cdn.test.com/${path}`; + if (sku === DEFAULT_SKU) { + return `${type}/${version}/${fileName}`; + } + 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( @@ -337,6 +346,8 @@ describe("Retrieve handler", () => { 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"); const res = createMockResponse(); @@ -357,6 +368,8 @@ describe("Retrieve handler", () => { it("serves the latest DB release when forceUpdate bypasses rollout", async () => { await createDbReleasePair("2.2.0", 100); await createDbReleasePair("2.3.0", 0); + mockArtifactSig("app", "2.3.0"); + mockArtifactSig("system", "2.3.0"); const res = createMockResponse(); @@ -395,6 +408,8 @@ describe("Retrieve handler", () => { 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(); @@ -415,6 +430,22 @@ describe("Retrieve handler", () => { }); }); + 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(); + + await Retrieve(createMockRequest({ deviceId: "stable-partial-sig-device" }), res); + + 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("selects the artifact compatible with the requested SKU", async () => { await createDbRelease("app", "3.2.0", 100, [ { @@ -447,7 +478,7 @@ describe("Retrieve handler", () => { }); }); - it("falls back to the latest release with a compatible artifact", async () => { + 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), @@ -466,23 +497,18 @@ describe("Retrieve handler", () => { ]); await createDbRelease("system", "3.3.1", 100); - const res = createMockResponse(); - - await Retrieve( - createMockRequest({ - deviceId: "sdmmc-compatible-fallback-device", - sku: SDMMC_SKU, - forceUpdate: "true", - }), - res, + await expect( + Retrieve( + createMockRequest({ + deviceId: "sdmmc-compatible-fallback-device", + sku: SDMMC_SKU, + forceUpdate: "true", + }), + createMockResponse(), + ), + ).rejects.toThrow( + 'Version 3.3.1 predates SKU support and cannot serve SKU "jetkvm-v2-sdmmc"', ); - - expect(jsonBody(res)).toMatchObject({ - appVersion: "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 () => { From 3ab1ad6408766c008ee66cc633d770575527a3ef Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 18:38:46 +0200 Subject: [PATCH 4/6] 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. --- .../migration.sql | 8 +-- scripts/seed.ts | 9 +-- scripts/sync-releases.ts | 55 ++++++++++--------- test/setup.ts | 9 +-- test/sync-releases.test.ts | 35 ++++++------ 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/prisma/migrations/20260427143200_add_release_artifacts/migration.sql b/prisma/migrations/20260427143200_add_release_artifacts/migration.sql index 1e95ef2..effb210 100644 --- a/prisma/migrations/20260427143200_add_release_artifacts/migration.sql +++ b/prisma/migrations/20260427143200_add_release_artifacts/migration.sql @@ -10,15 +10,15 @@ CREATE TABLE "ReleaseArtifact" ( ); -- 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", - CASE - WHEN "type" = 'app' THEN ARRAY['jetkvm-v2', 'jetkvm-v2-sdmmc']::TEXT[] - ELSE ARRAY['jetkvm-v2']::TEXT[] - END + ARRAY['jetkvm-v2']::TEXT[] FROM "Release"; -- CreateIndex diff --git a/scripts/seed.ts b/scripts/seed.ts index 6215991..3fa4caf 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -4,11 +4,12 @@ const prisma = new PrismaClient(); type ReleaseType = "app" | "system"; -const APP_COMPATIBLE_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; -const SYSTEM_COMPATIBLE_SKUS = ["jetkvm-v2"]; +// 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 type === "app" ? APP_COMPATIBLE_SKUS : SYSTEM_COMPATIBLE_SKUS; +function compatibleSkusForRelease(_type: ReleaseType): string[] { + return LEGACY_COMPATIBLE_SKUS; } // Development test users diff --git a/scripts/sync-releases.ts b/scripts/sync-releases.ts index 7c9f9a6..8ae0624 100644 --- a/scripts/sync-releases.ts +++ b/scripts/sync-releases.ts @@ -35,8 +35,10 @@ function artifactName(type: ReleaseType): string { return type === "app" ? "jetkvm_app" : "system.tar"; } -function legacyCompatibleSkus(type: ReleaseType, skus: string[]): string[] { - return type === "app" ? skus : [DEFAULT_SKU]; +// 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 { @@ -137,7 +139,7 @@ export async function collectReleaseArtifacts( { url: `${config.baseUrl}/${artifactPath}`, hash, - compatibleSkus: legacyCompatibleSkus(type, skus), + compatibleSkus: legacyCompatibleSkus(), }, ]; } @@ -192,40 +194,39 @@ async function syncRelease( return; } - const primaryArtifact = artifacts[0]; - const release = await prisma.release.upsert({ + // 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 } }, - update: { - url: primaryArtifact.url, - hash: primaryArtifact.hash, - }, - create: { + 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, + })), + }, }, }); - for (const artifact of artifacts) { - await prisma.releaseArtifact.upsert({ - where: { releaseId_url: { releaseId: release.id, url: artifact.url } }, - update: { - hash: artifact.hash, - compatibleSkus: artifact.compatibleSkus, - }, - create: { - releaseId: release.id, - url: artifact.url, - hash: artifact.hash, - compatibleSkus: artifact.compatibleSkus, - }, - }); - } - console.log( - `[sync-releases] ${type} ${version}: synced ${artifacts.length} artifact(s)`, + `[sync-releases] ${type} ${version}: created with ${artifacts.length} artifact(s)`, ); } diff --git a/test/setup.ts b/test/setup.ts index e4e8cce..8f95d77 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -26,8 +26,9 @@ export const testPrisma = new PrismaClient(); type ReleaseType = "app" | "system"; -const APP_COMPATIBLE_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; -const SYSTEM_COMPATIBLE_SKUS = ["jetkvm-v2"]; +// 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; @@ -105,8 +106,8 @@ export const seedReleases: SeedRelease[] = [ }, ]; -function compatibleSkusForSeedRelease(type: ReleaseType): string[] { - return type === "app" ? APP_COMPATIBLE_SKUS : SYSTEM_COMPATIBLE_SKUS; +function compatibleSkusForSeedRelease(_type: ReleaseType): string[] { + return LEGACY_COMPATIBLE_SKUS; } type SeedReleaseArtifactSource = Pick; diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index f006f6e..68267e1 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -59,7 +59,7 @@ describe("sync-releases script", () => { .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); }); - it("marks legacy app artifacts compatible with all known SKUs", async () => { + it("marks legacy app artifacts compatible with the default SKU only", async () => { mockS3HashFile("app", "9.9.1", "legacy-app-hash"); const artifacts = await collectReleaseArtifacts( @@ -73,7 +73,7 @@ describe("sync-releases script", () => { { url: "https://cdn.test.com/app/9.9.1/jetkvm_app", hash: "legacy-app-hash", - compatibleSkus: [DEFAULT_SKU, SDMMC_SKU], + compatibleSkus: [DEFAULT_SKU], }, ]); }); @@ -116,9 +116,11 @@ describe("sync-releases script", () => { ]); }); - it("syncs stable S3 artifacts into DB without changing existing rollout", async () => { + 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, @@ -146,35 +148,30 @@ describe("sync-releases script", () => { }); const systemRelease = await testPrisma.release.findUniqueOrThrow({ where: { version_type: { version, type: "system" } }, - include: { artifacts: { orderBy: { url: "asc" } } }, + 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, SDMMC_SKU], + 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.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${DEFAULT_SKU}/system.tar`, - hash: "system-hash-v2", - compatibleSkus: [DEFAULT_SKU], - }), - expect.objectContaining({ - url: `https://cdn.test.com/system/${version}/skus/${SDMMC_SKU}/system.tar`, - hash: "system-hash-sdmmc", - compatibleSkus: [SDMMC_SKU], - }), - ]), - ); + 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(); }); }); From 57ec20b041416f373e99d6ba8fe91d92d6da224c Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 18:45:31 +0200 Subject: [PATCH 5/6] 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. --- scripts/compare-releases.sh | 32 ++++++++--------- src/releases.ts | 70 ++++++++++++++----------------------- test/releases.test.ts | 27 +------------- 3 files changed, 42 insertions(+), 87 deletions(-) diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh index 99badfd..bb2b95c 100755 --- a/scripts/compare-releases.sh +++ b/scripts/compare-releases.sh @@ -6,7 +6,7 @@ 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-2" "jetkvm-3") +DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc") TRISTATE_VALUES=("__omit__" "false" "true") TMP_DIR="$(mktemp -d)" @@ -696,7 +696,7 @@ 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[@]} * ${#TRISTATE_VALUES[@]} * ${#APP_VERSION_VALUES[@]} * ${#SYSTEM_VERSION_VALUES[@]} * ${#DEFAULT_SKUS[@]} + ${#TRISTATE_VALUES[@]} * ${#DEFAULT_SKUS[@]} * 2 )) +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" @@ -705,21 +705,19 @@ log for device_id in "${DEVICE_IDS[@]}"; do for prerelease in "${TRISTATE_VALUES[@]}"; do - for force_update 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 6 - fi - query_keys=("deviceId" "prerelease" "forceUpdate" "appVersion" "systemVersion" "sku") - query_values=("$device_id" "$prerelease" "$force_update" "$app_version" "$system_version" "$sku") - run_case \ - "GET /releases deviceId=$device_id prerelease=$prerelease forceUpdate=$force_update appVersion=$app_version systemVersion=$system_version sku=$sku" \ - "/releases" \ - query_keys \ - query_values - done + 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 diff --git a/src/releases.ts b/src/releases.ts index 24b1222..35b0b7b 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -52,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"), @@ -60,7 +60,6 @@ const retrieveQuerySchema = z.object({ appVersion: queryString(), systemVersion: queryString(), sku: querySku(), - forceUpdate: queryBoolean(), }); type RetrieveQuery = z.infer; @@ -601,52 +600,35 @@ export async function Retrieve(req: Request, res: Response) { const latestAppRelease = await getLatestRelease("app", query.sku); const latestSystemRelease = await getLatestRelease("system", query.sku); + const defaultAppRelease = await getDefaultRelease("app", query.sku); + const defaultSystemRelease = await getDefaultRelease("system", query.sku); + + // 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), + ); - /* - 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( - dbReleaseToMetadata(latestAppRelease, query.sku), - dbReleaseToMetadata(latestSystemRelease, query.sku), - ); - } else { - const defaultAppRelease = await getDefaultRelease("app", query.sku); - const defaultSystemRelease = await getDefaultRelease("system", query.sku); - const defaultSystemMetadata = dbReleaseToMetadata(defaultSystemRelease, query.sku); - const defaultAppMetadata = dbReleaseToMetadata(defaultAppRelease, query.sku); - - responseJson = toRelease(defaultAppMetadata, defaultSystemMetadata); - - if ( - await isDeviceEligibleForLatestRelease( - latestAppRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setAppRelease( - responseJson, - dbReleaseToMetadata(latestAppRelease, query.sku), - ); - } + if ( + await isDeviceEligibleForLatestRelease( + latestAppRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setAppRelease(responseJson, dbReleaseToMetadata(latestAppRelease, query.sku)); + } - if ( - await isDeviceEligibleForLatestRelease( - latestSystemRelease.rolloutPercentage, - query.deviceId, - ) - ) { - setSystemRelease( - responseJson, - dbReleaseToMetadata(latestSystemRelease, query.sku), - ); - } + if ( + await isDeviceEligibleForLatestRelease( + latestSystemRelease.rolloutPercentage, + query.deviceId, + ) + ) { + setSystemRelease(responseJson, dbReleaseToMetadata(latestSystemRelease, query.sku)); } - // Stable responses are DB-backed; signatures live next to the selected artifacts. await addStableSigUrls(responseJson); return res.json(responseJson); diff --git a/test/releases.test.ts b/test/releases.test.ts index ac7fb4b..723c15b 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -365,29 +365,6 @@ describe("Retrieve handler", () => { }); }); - it("serves the latest DB release when forceUpdate bypasses rollout", async () => { - await createDbReleasePair("2.2.0", 100); - await createDbReleasePair("2.3.0", 0); - mockArtifactSig("app", "2.3.0"); - mockArtifactSig("system", "2.3.0"); - - const res = createMockResponse(); - - await Retrieve( - createMockRequest({ deviceId: "manual-update-device", forceUpdate: "true" }), - res, - ); - - expect(jsonBody(res)).toMatchObject({ - appVersion: "2.3.0", - appUrl: artifactUrl("app", "2.3.0"), - appSigUrl: `${artifactUrl("app", "2.3.0")}.sig`, - systemVersion: "2.3.0", - systemUrl: artifactUrl("system", "2.3.0"), - systemSigUrl: `${artifactUrl("system", "2.3.0")}.sig`, - }); - }); - it("applies app and system rollout independently", async () => { await createDbReleasePair("2.4.0", 100); await createDbRelease("app", "2.5.0", 100); @@ -464,7 +441,6 @@ describe("Retrieve handler", () => { createMockRequest({ deviceId: "sdmmc-device", sku: SDMMC_SKU, - forceUpdate: "true", }), res, ); @@ -502,7 +478,6 @@ describe("Retrieve handler", () => { createMockRequest({ deviceId: "sdmmc-compatible-fallback-device", sku: SDMMC_SKU, - forceUpdate: "true", }), createMockResponse(), ), @@ -523,7 +498,7 @@ describe("Retrieve handler", () => { const res = createMockResponse(); await Retrieve( - createMockRequest({ deviceId: "db-only-device", forceUpdate: "true" }), + createMockRequest({ deviceId: "db-only-device" }), res, ); From f7775a00a0bd7d3a94553c5119f2c4a3619b92ed Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Apr 2026 19:05:39 +0200 Subject: [PATCH 6/6] 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. --- src/releases.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 35b0b7b..be53836 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -499,14 +499,23 @@ async function getDefaultRelease(type: ReleaseType, sku: string): Promise r.artifacts.length > 0); + + if (compatibleReleases.length === 0) { + throw new NotFoundError( + `No default ${type} release available for SKU "${sku}"`, + ); + } + 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( @@ -598,10 +607,13 @@ export async function Retrieve(req: Request, res: Response) { return res.json(responseJson); } - const latestAppRelease = await getLatestRelease("app", query.sku); - const latestSystemRelease = await getLatestRelease("system", query.sku); - const defaultAppRelease = await getDefaultRelease("app", query.sku); - const defaultSystemRelease = await getDefaultRelease("system", query.sku); + const [latestAppRelease, latestSystemRelease, defaultAppRelease, defaultSystemRelease] = + await Promise.all([ + getLatestRelease("app", query.sku), + getLatestRelease("system", query.sku), + getDefaultRelease("app", query.sku), + getDefaultRelease("system", query.sku), + ]); // Background update checks follow rollout percentages so new releases roll // out gradually. Devices outside the bucket fall back to the default (the