diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 080a46a..493b1cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,7 +49,7 @@ model Release { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt url String - type String @default("app") // "app" or "system" + type String @default("app") // R2 prefix: "app", "system" or "mini" (see src/skus.ts) hash String artifacts ReleaseArtifact[] diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh index 8bab1e6..841bfaa 100755 --- a/scripts/compare-releases.sh +++ b/scripts/compare-releases.sh @@ -11,6 +11,10 @@ PROD_BASE="${PROD_BASE:-https://api.jetkvm.com}" # Together they exercise both eligible and ineligible rollout paths. DEFAULT_DEVICE_IDS=("compare-device-1" "compare-device-2") DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc") +# SKUs whose responses are allowed to differ from production until the SKU +# table is deployed there: the two Mini variants (no app artifact, no release +# yet) and one unregistered SKU (400 locally, 404 on the old code). +EXTRA_SKUS="${EXTRA_SKUS:-jetkvm-mini-ethernet jetkvm-mini-wireless jetkvm-v3}" TRISTATE_VALUES=("__omit__" "false" "true") TMP_DIR="$(mktemp -d)" @@ -43,6 +47,8 @@ Environment overrides: 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) + EXTRA_SKUS Space-separated SKUs compared with the expected 400-vs-404 + deviation accepted (default: both Mini SKUs and jetkvm-v3) Examples: scripts/compare-releases.sh @@ -160,12 +166,16 @@ try: except Exception: normalized_path.write_text(body) else: + # Resolver bookkeeping that older deploys still serialize; the device + # never reads it, so it is not part of the comparison. + ignored_suffixes = ("CachedAt", "MaxSatisfying") + def scrub(value): if isinstance(value, dict): return { key: scrub(child) for key, child in value.items() - if not key.endswith("CachedAt") + if not key.endswith(ignored_suffixes) } if isinstance(value, list): return [scrub(item) for item in value] @@ -261,8 +271,11 @@ if left_keys != {"name", "message"} or right_keys != {"name", "message"}: # Both messages mean "no release compatible with this SKU is available"; # the wording differs across deploys but the device sees the same 404. no_compat_patterns = [ + re.compile(r'^Version .+ has no artifact for SKU "([^"]+)"$'), re.compile(r'^Version .+ predates SKU support and cannot serve SKU "([^"]+)"$'), - re.compile(r'^No default (?:app|system) release available for SKU "([^"]+)"$'), + re.compile(r'^SKU "([^"]+)" is not available for version .+$'), + re.compile(r'^No default (?:app|system|mini) release available for SKU "([^"]+)"$'), + re.compile(r'^No release found for type \S+ and SKU "([^"]+)"$'), ] def canonicalize(message): @@ -286,8 +299,9 @@ 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' + EXTRA_SKUS="$EXTRA_SKUS" python3 - "$query" "${left_prefix}.meta" "${right_prefix}.meta" "${left_prefix}.normalized" "${right_prefix}.normalized" <<'PY' import json +import os import sys from pathlib import Path from urllib.parse import parse_qs @@ -318,6 +332,25 @@ right_meta = parse_meta(right_meta_path) left_body = load_json(left_body_path) right_body = load_json(right_body_path) +def accept(reason): + print(reason) + raise SystemExit(0) + +# Accepted behavior change: +# For SKUs in EXTRA_SKUS (unregistered, or registered but without releases or +# without the requested artifact) the SKU table answers 400 or 404 with its +# own wording; the code before it answered 400 or 404 from a different check. +# Both sides refusing with a client error means the same thing here. +if one("sku") in os.environ.get("EXTRA_SKUS", "").split(): + def refuses(meta, body): + return ( + meta.get("http_code") in ("400", "404") + and isinstance(body, dict) + and str(body.get("name", "")).endswith("Error") + ) + if refuses(left_meta, left_body) and refuses(right_meta, right_body): + accept("SKU table: both sides refuse this SKU with a 4xx (wording and status may differ)") + # 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. @@ -338,7 +371,7 @@ if not isinstance(left_body, dict) or left_body.get("name") != "NotFoundError": if not isinstance(right_body, dict) or not right_body.get("appVersion") or not right_body.get("systemVersion"): raise SystemExit(1) -raise SystemExit(0) +accept("stable dev/prerelease version constraints are DB-only locally") PY } @@ -494,11 +527,12 @@ write_case_result() { fi fi - if (( failed == 1 )) && is_accepted_deviation "$query" "$left_prefix" "$right_prefix"; then + if (( failed == 1 )) && accepted_reason="$(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 + else + accepted_reason="" fi { @@ -704,10 +738,24 @@ STABLE_SYSTEM_VERSION="${stable_versions[1]:-}" PRERELEASE_APP_VERSION="${prerelease_versions[0]:-}" PRERELEASE_SYSTEM_VERSION="${prerelease_versions[1]:-}" +# Range constraints around the live stable version: the caret range resolves +# to the newest patch of the current minor, "/ upload to opt in. -function legacyCompatibleSkus(): string[] { - return [DEFAULT_SKU]; -} - const DEFAULT_ROLLOUT_PERCENTAGE = 10; type ReleaseOutcome = @@ -503,10 +492,18 @@ export async function collectReleaseArtifacts( type: ReleaseType, version: string, ): Promise { - const skus = config.skus ?? KNOWN_SKUS; - const artifactFileName = artifactName(type); + const skus = config.skus ?? skusForPrefix(type); + const artifactFileName = otaFileForPrefix(type); if (!(await versionHasSkuSupport(clients.s3Client, config.bucketName, type, version))) { + // Pre-SKU artifacts (no skus/ folder) are only safe on the SKUs that + // predate the layout. A type with no legacy form treats a version + // without skus/ as an upload mistake, not a release. + const compatibleSkus = legacyCompatibleSkus(type); + if (compatibleSkus.length === 0) { + return []; + } + const artifactPath = `${type}/${version}/${artifactFileName}`; const hash = await readHash(clients.s3Client, config.bucketName, artifactPath); if (!hash) { @@ -517,7 +514,7 @@ export async function collectReleaseArtifacts( { url: `${config.baseUrl}/${artifactPath}`, hash, - compatibleSkus: legacyCompatibleSkus(), + compatibleSkus, }, ]; } @@ -639,7 +636,7 @@ export async function syncReleases( }; let abortedAt: { type: ReleaseType; version: string } | null = null; - outer: for (const type of ["app", "system"] as const) { + outer: for (const type of OTA_PREFIXES) { const versions = await listStableVersions(clients.s3Client, config.bucketName, type); for (const version of versions) { diff --git a/src/devices.ts b/src/devices.ts index ba42ccf..b6d54d4 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -10,6 +10,22 @@ import { import * as crypto from "crypto"; import { authenticated } from "./auth"; import { activeConnections } from "./webrtc-signaling"; +import { effectiveSku } from "./skus"; + +/** + * Version and SKU are known only while the device holds a signaling + * connection; they arrive as request headers and live in memory, not in the + * database. Offline devices report null for both. An online device that + * sends no SKU header is the original hardware. + */ +function liveDeviceState(id: string) { + const conn = activeConnections.get(id); + return { + online: !!conn, + version: conn?.version || null, + sku: conn ? effectiveSku(conn.sku) : null, + }; +} export const List = async (req: express.Request, res: express.Response) => { const idToken = req.session?.id_token; @@ -24,16 +40,10 @@ export const List = async (req: express.Request, res: express.Response) => { }); return res.json({ - devices: devices.map(device => { - const activeDevice = activeConnections.get(device.id); - const version = activeDevice?.[2] || null; - - return { - ...device, - online: !!activeDevice, - version, - }; - }), + devices: devices.map(device => ({ + ...device, + ...liveDeviceState(device.id), + })), }); } else { throw new BadRequestError("Token is not from Google"); @@ -55,7 +65,7 @@ export const Retrieve = async ( }); if (!device) throw new NotFoundError("Device not found"); - return res.status(200).json({ device }); + return res.status(200).json({ device: { ...device, ...liveDeviceState(device.id) } }); }; export const Update = async ( @@ -137,9 +147,8 @@ export const Delete = async ( // We just removed the device, so we should close any running open socket connections const conn = activeConnections.get(id); if (conn) { - const [socket] = conn; - socket.send("Deregistered from server"); - socket.close(); + conn.ws.send("Deregistered from server"); + conn.ws.close(); } return res.status(204).send(); diff --git a/src/releases.ts b/src/releases.ts index fa12821..d34ba83 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -16,24 +16,17 @@ import { objectKeyFromArtifactUrl, streamToString, toSemverRange, - verifyHash, } from "./helpers"; import { z, ZodError } from "zod"; - -const DEFAULT_SKU = "jetkvm-v2"; -type ReleaseType = "app" | "system"; - -/** - * Recovery image filename per SKU. eMMC variants are flashed via DFU + the - * Rockchip upgrade tool (RKDevTool format), while SDMMC variants are written - * to a microSD with balenaEtcher (dd-format zip). Add a new SKU here when - * shipping a new hardware variant; unmapped SKUs are rejected so a typo - * doesn't silently fall back to the wrong artifact. - */ -const RECOVERY_ARTIFACT_BY_SKU: Record = { - "jetkvm-v2": "update.img", - "jetkvm-v2-sdmmc": "update_sd.img.zip", -}; +import { + DEFAULT_SKU, + artifactFor, + isKnownSku, + legacyCompatibleSkus, + otaArtifacts, + type OtaKind, + type Artifact, +} from "./skus"; /** Query param schema builders for common patterns */ const queryString = () => @@ -50,7 +43,8 @@ const querySku = () => z .string() .optional() - .transform(v => v || DEFAULT_SKU); + .transform(v => v || DEFAULT_SKU) + .refine(isKnownSku, { error: issue => `Unknown SKU "${issue.input}"` }); /** * Schema for redirect endpoints (RetrieveLatestApp, RetrieveLatestSystemRecovery). @@ -96,8 +90,6 @@ export interface ReleaseMetadata { version: string; url: string; hash: string; - _cachedAt?: number; - _maxSatisfying?: string; } interface DbRelease { @@ -145,6 +137,15 @@ export function clearCaches() { const bucketName = process.env.R2_BUCKET; const baseUrl = process.env.R2_CDN_URL; +/** + * The one error for "this version ships no artifact for this SKU", whichever + * path detects it: a skus/ folder without this SKU, a pre-SKU version asked + * for by non-default hardware, or a DB release with no compatible artifact. + */ +function noArtifactForSku(version: string, sku: string): NotFoundError { + return new NotFoundError(`Version ${version} has no artifact for SKU "${sku}"`); +} + /** * Checks if an object exists in S3/R2 by attempting a HeadObjectCommand. * Returns true if the object exists, false otherwise. @@ -170,10 +171,7 @@ async function s3ObjectExists(key: string): Promise { * Checks if a version was uploaded with SKU folder structure. * Returns true if any skus/ subfolder exists for this version. */ -async function versionHasSkuSupport( - prefix: "app" | "system", - version: string, -): Promise { +async function versionHasSkuSupport(prefix: string, version: string): Promise { const response = await s3Client.send( new ListObjectsV2Command({ Bucket: bucketName, @@ -192,45 +190,41 @@ async function versionHasSkuSupport( * - Fails if the requested SKU is not available * * For legacy versions (no skus/ folder): - * - Returns legacy path for default SKU - * - Fails for non-default SKUs because legacy firmware predates + * - Returns legacy path for SKUs that predate the SKU layout + * - Fails for every other SKU because legacy firmware predates * that hardware and may not be compatible * - * @param prefix - The prefix folder ("app" or "system") + * @param prefix - The R2 folder the artifact lives under * @param version - The version string * @param sku - SKU identifier (defaults to jetkvm-v2 from schema) - * @param artifactOverride - Optional artifact name override (defaults based on prefix) + * @param file - The artifact's file name */ async function resolveArtifactPath( - prefix: "app" | "system", + prefix: string, version: string, sku: string, - artifactOverride?: string, + file: string, ): Promise { - const artifact = artifactOverride ?? (prefix === "app" ? "jetkvm_app" : "system.tar"); - if (await versionHasSkuSupport(prefix, version)) { - const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`; + const skuPath = `${prefix}/${version}/skus/${sku}/${file}`; if (await s3ObjectExists(skuPath)) { return skuPath; } - throw new NotFoundError(`SKU "${sku}" is not available for version ${version}`); + throw noArtifactForSku(version, sku); } // SKU defaults to "jetkvm-v2" via zod schema when not provided. // - // For legacy versions (pre-SKU folder structure), we only serve the default SKU. - // This prevents newer hardware variants from rolling back to old firmware - // that may not have compatible binaries for their hardware. - if (sku === DEFAULT_SKU) { - return `${prefix}/${version}/${artifact}`; + // For legacy versions (pre-SKU folder structure), we only serve the SKUs + // that predate the layout. This prevents newer hardware variants from + // rolling back to old firmware that may not have compatible binaries. + if (legacyCompatibleSkus(prefix).includes(sku)) { + return `${prefix}/${version}/${file}`; } - throw new NotFoundError( - `Version ${version} predates SKU support and cannot serve SKU "${sku}"`, - ); + throw noArtifactForSku(version, sku); } /** @@ -238,16 +232,16 @@ async function resolveArtifactPath( * Results are cached for 5 minutes. */ async function resolveSigUrl( - prefix: "app" | "system", + artifact: Artifact, version: string, sku: string, ): Promise { - const cacheKey = `${prefix}-${version}-${sku}`; + const cacheKey = `${artifact.prefix}-${version}-${sku}`; const cached = sigUrlCache.get(cacheKey); if (cached !== undefined) return cached === MISSING_SIG_URL ? undefined : cached; try { - const path = await resolveArtifactPath(prefix, version, sku); + const path = await resolveArtifactPath(artifact.prefix, version, sku, artifact.file); const sigKey = `${path}.sig`; if (await s3ObjectExists(sigKey)) { const url = `${baseUrl}/${sigKey}`; @@ -273,33 +267,27 @@ async function resolveSigUrl( * Transient S3 errors are logged but don't block the response — sigUrl is optional. */ async function enrichWithSigUrls(release: Release, sku: string): Promise { - const [appSigUrl, systemSigUrl] = await Promise.all([ - release.appVersion - ? resolveSigUrl("app", release.appVersion, sku).catch(e => { - console.error(`Failed to resolve app sig URL for ${release.appVersion}:`, e); - return undefined; - }) - : undefined, - release.systemVersion - ? resolveSigUrl("system", release.systemVersion, sku).catch(e => { - console.error( - `Failed to resolve system sig URL for ${release.systemVersion}:`, - e, - ); - return undefined; - }) - : undefined, - ]); - if (appSigUrl) release.appSigUrl = appSigUrl; - if (systemSigUrl) release.systemSigUrl = systemSigUrl; + await Promise.all( + otaArtifacts(sku).map(async ({ kind, prefix, file }) => { + const version = release[`${kind}Version`]; + if (!version) return; + try { + const sigUrl = await resolveSigUrl({ prefix, file }, version, sku); + if (sigUrl) release[`${kind}SigUrl`] = sigUrl; + } catch (e) { + console.error(`Failed to resolve ${kind} sig URL for ${version}:`, e); + } + }), + ); } async function getLatestVersion( - prefix: "app" | "system", + artifact: Artifact, includePrerelease: boolean, maxSatisfying: string = "*", sku: string, ): Promise { + const { prefix } = artifact; const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; const cached = releaseCache.get(cacheKey); if (cached) return cached; @@ -335,7 +323,7 @@ async function getLatestVersion( ); } - const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku); + const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku, artifact.file); const url = `${baseUrl}/${selectedPath}`; const hashResponse = await s3Client.send( @@ -352,52 +340,42 @@ async function getLatestVersion( version: latestVersion, url, hash, - _cachedAt: Date.now(), - _maxSatisfying: maxSatisfying, }; releaseCache.set(cacheKey, release); return release; } +/** Wire shape of the /releases response. Only these fields are serialized. */ interface Release { appVersion: string; appUrl: string; appHash: string; appSigUrl?: string; - appCachedAt?: number; - appMaxSatisfying?: string; systemVersion: string; systemUrl: string; systemHash: string; systemSigUrl?: string; - systemCachedAt?: number; - systemMaxSatisfying?: string; } -function setAppRelease(release: Release, appRelease: ReleaseMetadata) { - release.appVersion = appRelease.version; - release.appUrl = appRelease.url; - release.appHash = appRelease.hash; - release.appCachedAt = appRelease._cachedAt; - release.appMaxSatisfying = appRelease._maxSatisfying; -} +type OtaReleases = Partial>; -function setSystemRelease(release: Release, systemRelease: ReleaseMetadata) { - release.systemVersion = systemRelease.version; - release.systemUrl = systemRelease.url; - release.systemHash = systemRelease.hash; - release.systemCachedAt = systemRelease._cachedAt; - release.systemMaxSatisfying = systemRelease._maxSatisfying; +function setOtaRelease(release: Release, kind: OtaKind, metadata: ReleaseMetadata) { + release[`${kind}Version`] = metadata.version; + release[`${kind}Url`] = metadata.url; + release[`${kind}Hash`] = metadata.hash; } -function toRelease( - appRelease?: ReleaseMetadata, - systemRelease?: ReleaseMetadata, -): Release { +/** + * Builds the response object. Artifacts a product does not ship are left out + * entirely: the device treats an absent one as "not offered". + */ +function toRelease(offers: OtaReleases): Release { const release: Partial = {}; - if (appRelease) setAppRelease(release as Release, appRelease); - if (systemRelease) setSystemRelease(release as Release, systemRelease); + for (const kind of ["app", "system"] as const) { + const metadata = offers[kind]; + if (metadata) setOtaRelease(release as Release, kind, metadata); + } return release as Release; } @@ -434,20 +412,25 @@ async function addStableSigUrls(release: Release): Promise { if (systemSigUrl) release.systemSigUrl = systemSigUrl; } +type VersionConstraints = Record; + async function getReleaseFromS3( includePrerelease: boolean, - { - appVersion, - systemVersion, - sku, - }: { appVersion?: string; systemVersion?: string; sku: string }, + sku: string, + constraints: VersionConstraints, ): Promise { - const [appRelease, systemRelease] = await Promise.all([ - getLatestVersion("app", includePrerelease, appVersion, sku), - getLatestVersion("system", includePrerelease, systemVersion, sku), - ]); - - return toRelease(appRelease, systemRelease); + const offers: OtaReleases = {}; + await Promise.all( + otaArtifacts(sku).map(async ({ kind, prefix, file }) => { + offers[kind] = await getLatestVersion( + { prefix, file }, + includePrerelease, + constraints[kind], + sku, + ); + }), + ); + return toRelease(offers); } async function isDeviceEligibleForLatestRelease( @@ -475,35 +458,28 @@ function compatibleReleaseSelect(sku: string) { } as const; } -function dbReleaseToMetadata( - release: DbRelease, - sku: string, - maxSatisfying?: string, -): ReleaseMetadata { +function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { const artifact = release.artifacts[0]; if (!artifact) { - throw new NotFoundError( - `Version ${release.version} predates SKU support and cannot serve SKU "${sku}"`, - ); + throw noArtifactForSku(release.version, sku); } return { version: release.version, url: artifact.url, hash: artifact.hash, - _maxSatisfying: maxSatisfying, }; } -async function getDefaultRelease(type: ReleaseType, sku: string): Promise { +async function getDefaultRelease(prefix: string, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ - where: { type, rolloutPercentage: 100 }, + where: { type: prefix, rolloutPercentage: 100 }, select: compatibleReleaseSelect(sku), }); if (rolledOutReleases.length === 0) { throw new InternalServerError( - `No default release found for type ${type} and SKU "${sku}"`, + `No default release found for type ${prefix} and SKU "${sku}"`, ); } @@ -514,7 +490,7 @@ async function getDefaultRelease(type: ReleaseType, sku: string): Promise { - return getReleaseByRange(type, sku, "*"); +async function getLatestRelease(prefix: string, sku: string): Promise { + return getReleaseByRange(prefix, sku, "*"); } async function getReleaseByRange( - type: ReleaseType, + prefix: string, sku: string, range: string, ): Promise { const releases = await prisma.release.findMany({ - where: { type }, + where: { type: prefix }, select: compatibleReleaseSelect(sku), }); if (releases.length === 0) { - throw new NotFoundError(`No release found for type ${type} and SKU "${sku}"`); + throw new NotFoundError(`No release found for type ${prefix} and SKU "${sku}"`); } const latestVersion = semver.maxSatisfying( @@ -558,12 +534,12 @@ async function getReleaseByRange( ) as string; if (!latestVersion) { - throw new NotFoundError(`No ${type} release found that satisfies ${range}`); + throw new NotFoundError(`No ${prefix} 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}`); + throw new NotFoundError(`No ${prefix} release found that satisfies ${range}`); } return latestRelease; @@ -571,20 +547,20 @@ async function getReleaseByRange( export async function Retrieve(req: Request, res: Response) { const query = parseQuery(retrieveQuerySchema, req); + const { sku, deviceId } = query; - const appVersion = toSemverRange(query.appVersion); - const systemVersion = toSemverRange(query.systemVersion); - const skipRollout = appVersion !== "*" || systemVersion !== "*"; + const artifacts = otaArtifacts(sku); + const constraints: VersionConstraints = { + app: toSemverRange(query.appVersion), + system: toSemverRange(query.systemVersion), + }; + const skipRollout = artifacts.some(({ kind }) => constraints[kind] !== "*"); // 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, - }); + remoteRelease = await getReleaseFromS3(query.prerelease, sku, constraints); } catch (error) { console.error(error); if (error instanceof NotFoundError) { @@ -593,66 +569,47 @@ export async function Retrieve(req: Request, res: Response) { throw new InternalServerError(`Failed to get the latest release from S3: ${error}`); } - await enrichWithSigUrls(remoteRelease, query.sku); + await enrichWithSigUrls(remoteRelease, sku); return res.json(remoteRelease); } // Version-constrained stable requests skip rollout but still read DB metadata. if (skipRollout) { - const responseJson = toRelease( - dbReleaseToMetadata( - await getReleaseByRange("app", query.sku, appVersion), - query.sku, - appVersion, - ), - dbReleaseToMetadata( - await getReleaseByRange("system", query.sku, systemVersion), - query.sku, - systemVersion, - ), - ); + const constrained: OtaReleases = {}; + for (const { kind, prefix } of artifacts) { + constrained[kind] = dbReleaseToMetadata( + await getReleaseByRange(prefix, sku, constraints[kind]), + sku, + ); + } + const responseJson = toRelease(constrained); await addStableSigUrls(responseJson); return res.json(responseJson); } - 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 // newest 100%-rolled-out release). If the latest release lacks a compatible // artifact for this SKU (e.g. a SKU-specific build hasn't shipped yet) we // silently keep the default rather than 404 the whole request. - const responseJson = toRelease( - dbReleaseToMetadata(defaultAppRelease, query.sku), - dbReleaseToMetadata(defaultSystemRelease, query.sku), + const offered: OtaReleases = {}; + await Promise.all( + artifacts.map(async ({ kind, prefix }) => { + // Sequential on purpose: when the prefix has no releases at all, the + // 404 from the latest lookup must win over the 500 from the default + // lookup, which the parallel form left to query timing. + const latest = await getLatestRelease(prefix, sku); + const fallback = await getDefaultRelease(prefix, sku); + + const useLatest = + latest.artifacts.length > 0 && + (await isDeviceEligibleForLatestRelease(latest.rolloutPercentage, deviceId)); + + offered[kind] = dbReleaseToMetadata(useLatest ? latest : fallback, sku); + }), ); - if ( - latestAppRelease.artifacts.length > 0 && - (await isDeviceEligibleForLatestRelease( - latestAppRelease.rolloutPercentage, - query.deviceId, - )) - ) { - setAppRelease(responseJson, dbReleaseToMetadata(latestAppRelease, query.sku)); - } - - if ( - latestSystemRelease.artifacts.length > 0 && - (await isDeviceEligibleForLatestRelease( - latestSystemRelease.rolloutPercentage, - query.deviceId, - )) - ) { - setSystemRelease(responseJson, dbReleaseToMetadata(latestSystemRelease, query.sku)); - } - + const responseJson = toRelease(offered); await addStableSigUrls(responseJson); return res.json(responseJson); @@ -681,25 +638,31 @@ function releaseCacheKey(prefix: string, query: LatestQuery): string { return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku}`; } +/** + * 302 to the newest recovery image for the requested SKU. The product table + * says which prefix holds it and what it is called. The route keeps its + * historical name. + */ export const RetrieveLatestSystemRecovery = cachedRedirect( - query => releaseCacheKey("system-recovery", query), + query => releaseCacheKey("recovery", query), async query => { - const recoveryArtifact = RECOVERY_ARTIFACT_BY_SKU[query.sku]; - if (!recoveryArtifact) { - throw new BadRequestError(`Unsupported SKU "${query.sku}"`); + const recovery = artifactFor(query.sku, "recovery"); + if (!recovery) { + throw new BadRequestError( + `SKU "${query.sku}" has no downloadable recovery image`, + ); } - // Get the latest system recovery image from S3. It's stored in the system/ folder. const listCommand = new ListObjectsV2Command({ Bucket: bucketName, - Prefix: "system/", + Prefix: `${recovery.prefix}/`, Delimiter: "/", }); const response = await s3Client.send(listCommand); // Extract version folder names if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError(`No versions found under prefix system recovery image`); + throw new NotFoundError(`No versions found under prefix ${recovery.prefix} for recovery`); } // Get the latest version @@ -712,101 +675,80 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( }) as string; if (!latestVersion) { - throw new NotFoundError("No valid system recovery versions found"); + throw new NotFoundError(`No valid ${recovery.prefix} recovery versions found`); } - // Resolve the artifact path with SKU support; the artifact filename - // depends on the SKU (eMMC = update.img, SDMMC = update_sd.img.zip). const artifactPath = await resolveArtifactPath( - "system", + recovery.prefix, latestVersion, query.sku, - recoveryArtifact, + recovery.file, ); - const [firmwareFile, hashFile] = await Promise.all([ - // TODO: store file hash using custom header to avoid extra request - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: artifactPath, - }), - ), - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${artifactPath}.sha256`, - }), - ), - ]); - - if (!firmwareFile.Body || !hashFile.Body) { - throw new NotFoundError( - `No system recovery image or hash file not found for version ${latestVersion}`, - ); + if (!(await s3ObjectExists(artifactPath))) { + throw new NotFoundError(`Recovery image not found for version ${latestVersion}`); } - await verifyHash(firmwareFile, hashFile, "system recovery image hash does not match"); - - console.log("system recovery image hash matches", latestVersion); - return `${baseUrl}/${artifactPath}`; }, ); -export const RetrieveLatestApp = cachedRedirect( - query => releaseCacheKey("app", query), - async query => { - // Get the latest version - const listCommand = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: "app/", - Delimiter: "/", - }); - const response = await s3Client.send(listCommand); +/** + * 302 to the newest over-the-air artifact of one kind for the requested SKU. + * Integrity is checked at publish time (the .sha256 sidecar is written by the + * release script, the sync script verifies hash and signature); here the + * object only has to exist. The product table + * says which prefix holds it, so the same URL serves every product. Used by + * build tooling (rv1106-system pulls the app binary into the system image) + * and by flashing scripts. + */ +function latestArtifactRedirect(kind: OtaKind) { + return cachedRedirect( + query => releaseCacheKey(kind, query), + async query => { + const artifact = artifactFor(query.sku, kind); + if (!artifact) { + throw new BadRequestError(`SKU "${query.sku}" has no ${kind} artifact`); + } + const { prefix } = artifact; - if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError("No app versions found"); - } + const listCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: `${prefix}/`, + Delimiter: "/", + }); + const response = await s3Client.send(listCommand); - const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( - v => semver.valid(v), - ); + if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { + throw new NotFoundError(`No ${prefix} versions found`); + } - const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease: query.prerelease, - }) as string; + const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( + v => semver.valid(v), + ); - if (!latestVersion) { - throw new NotFoundError("No valid app versions found"); - } + const latestVersion = semver.maxSatisfying(versions, "*", { + includePrerelease: query.prerelease, + }) as string; - // Resolve the artifact path with SKU support - const artifactPath = await resolveArtifactPath("app", latestVersion, query.sku); - - // Get the app file and its hash - const [appFile, hashFile] = await Promise.all([ - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: artifactPath, - }), - ), - s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${artifactPath}.sha256`, - }), - ), - ]); - - if (!appFile.Body || !hashFile.Body) { - throw new NotFoundError(`App or hash file not found for version ${latestVersion}`); - } + if (!latestVersion) { + throw new NotFoundError(`No valid ${prefix} versions found`); + } - await verifyHash(appFile, hashFile, "app hash does not match"); + const artifactPath = await resolveArtifactPath( + prefix, + latestVersion, + query.sku, + artifact.file, + ); - console.log("App hash matches", latestVersion); - return `${baseUrl}/${artifactPath}`; - }, -); + if (!(await s3ObjectExists(artifactPath))) { + throw new NotFoundError(`${prefix} artifact not found for version ${latestVersion}`); + } + + return `${baseUrl}/${artifactPath}`; + }, + ); +} + +export const RetrieveLatestApp = latestArtifactRedirect("app"); diff --git a/src/skus.ts b/src/skus.ts new file mode 100644 index 0000000..b58f337 --- /dev/null +++ b/src/skus.ts @@ -0,0 +1,156 @@ +/** + * One row per hardware SKU: the artifacts a device with that SKU can get. + * + * app installed over the air, reported in the app* fields of /releases + * system installed over the air, reported in the system* fields of /releases + * recovery full image flashed by cable, served by /releases/system_recovery/latest + * + * Each value is `/`. The prefix is the R2 folder the artifact + * lives under and the `Release.type` value in the database; artifacts that + * are released together share a prefix and a version number. The full key + * is `//skus//` (+ .sha256, optional .sig). + * + * Keep this table in sync with the firmware build tooling + * (rv1106-system/scripts/common.sh, kvm/Makefile APP_SKUS, the kvm-mini + * release script). Unknown SKUs are rejected at the API boundary so a typo + * can never fall back to firmware built for different hardware. + */ + +export type OtaKind = "app" | "system"; +export type ArtifactKind = OtaKind | "recovery"; + +export interface SkuArtifacts { + app?: string; + system?: string; + recovery?: string; + /** + * Uploads made before the skus/ layout existed (`//`, + * no skus/ folder) belong to this SKU. + */ + legacyUploads?: boolean; +} + +export const SKUS: Record = { + // JetKVM (RV1106, Linux). The Go app and the system rootfs are released + // separately, so they have separate prefixes and version numbers. The + // recovery image is built with the rootfs and shares its version. + // eMMC variant: flashed via DFU + the Rockchip upgrade tool (RKDevTool format). + "jetkvm-v2": { + app: "app/jetkvm_app", + system: "system/system.tar", + recovery: "system/update.img", + legacyUploads: true, + }, + // SDMMC variant: written to a microSD with balenaEtcher (dd-format zip). + "jetkvm-v2-sdmmc": { + app: "app/jetkvm_app", + system: "system/system.tar", + recovery: "system/update_sd.img.zip", + }, + // JetKVM Mini (ESP32-P4). One firmware image is the whole system: + // FreeRTOS, drivers, the application, the web UI and the ESP32-C5 Wi-Fi + // co-processor firmware. There is no separate app. The Ethernet (IP101) + // and wireless (ESP32-C5 over SDIO) boards are separate builds. The + // recovery image is the merged full-flash binary (bootloader, partition + // table, firmware in the first OTA slot, erased OTA selection data), + // written with esptool or a browser flasher at offset 0. + "jetkvm-mini-ethernet": { + system: "mini/jetkvm-mini.bin", + recovery: "mini/jetkvm-mini-full.bin", + }, + "jetkvm-mini-wireless": { + system: "mini/jetkvm-mini.bin", + recovery: "mini/jetkvm-mini-full.bin", + }, +}; + +export const DEFAULT_SKU = "jetkvm-v2"; +export const KNOWN_SKUS: readonly string[] = Object.keys(SKUS); +export const OTA_KINDS: readonly OtaKind[] = ["app", "system"]; + +export function isKnownSku(sku: string): boolean { + return Object.prototype.hasOwnProperty.call(SKUS, sku); +} + +/** + * Coerces a SKU reported by a device (header or body value) to a registered + * SKU, or null when absent or unknown. Unknown values are dropped rather than + * stored so the cloud never advertises hardware it has no definition for. + */ +export function normalizeSku(value: unknown): string | null { + return typeof value === "string" && isKnownSku(value) ? value : null; +} + +/** + * SKU to assume for a connected device that did not report one. Only the + * original JetKVM hardware predates SKU reporting, so it is the default + * everywhere, mirroring the /releases query default. + */ +export function effectiveSku(sku: string | null | undefined): string { + return sku ?? DEFAULT_SKU; +} + +export interface Artifact { + prefix: string; + file: string; +} + +export interface OtaArtifact extends Artifact { + kind: OtaKind; +} + +function parseArtifact(value: string): Artifact { + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) { + throw new Error(`Artifact "${value}" must be /`); + } + return { prefix: value.slice(0, slash), file: value.slice(slash + 1) }; +} + +/** The artifact of one kind for a SKU, or undefined when the SKU has none. */ +export function artifactFor(sku: string, kind: ArtifactKind): Artifact | undefined { + const row = SKUS[sku]; + if (!row) { + throw new Error(`Unknown SKU "${sku}"`); + } + const value = row[kind]; + return value ? parseArtifact(value) : undefined; +} + +/** The over-the-air artifacts of a SKU: app, then system, whichever exist. */ +export function otaArtifacts(sku: string): OtaArtifact[] { + return OTA_KINDS.flatMap(kind => { + const artifact = artifactFor(sku, kind); + return artifact ? [{ kind, ...artifact }] : []; + }); +} + +const unique = (values: T[]): T[] => Array.from(new Set(values)); + +const OTA_ROWS = KNOWN_SKUS.flatMap(sku => otaArtifacts(sku).map(artifact => ({ sku, ...artifact }))); + +/** Every prefix that holds over-the-air artifacts, in table order. */ +export const OTA_PREFIXES: readonly string[] = unique(OTA_ROWS.map(row => row.prefix)); + +/** File name of the over-the-air artifact stored under a prefix. */ +export function otaFileForPrefix(prefix: string): string { + const files = unique(OTA_ROWS.filter(row => row.prefix === prefix).map(row => row.file)); + if (files.length !== 1) { + throw new Error(`Prefix "${prefix}" holds ${files.length} over-the-air files, expected one`); + } + return files[0]; +} + +/** SKUs that receive the over-the-air artifact stored under a prefix. */ +export function skusForPrefix(prefix: string): string[] { + return unique(OTA_ROWS.filter(row => row.prefix === prefix).map(row => row.sku)); +} + +/** + * SKUs that may consume a pre-SKU upload (`//` with + * no skus/ folder) under a prefix. Every other SKU requires an explicit + * `skus//` upload. + */ +export function legacyCompatibleSkus(prefix: string): string[] { + return skusForPrefix(prefix).filter(sku => SKUS[sku].legacyUploads); +} diff --git a/src/webrtc-signaling.ts b/src/webrtc-signaling.ts index 879b68b..c29f788 100644 --- a/src/webrtc-signaling.ts +++ b/src/webrtc-signaling.ts @@ -8,10 +8,19 @@ import { Socket } from "node:net"; import { Device } from "@prisma/client"; import { Server, ServerResponse } from "node:http"; import { cookieSessionMiddleware } from "."; +import { effectiveSku, normalizeSku } from "./skus"; + +export interface DeviceConnection { + ws: WebSocket; + ip: string; + /** X-App-Version header, null for firmware that does not send it. */ + version: string | null; + /** X-Device-SKU header after validation, null when absent or unknown. */ + sku: string | null; +} // Maintain the shared state -export const activeConnections: Map = - new Map(); // [deviceWs, ip, version] +export const activeConnections: Map = new Map(); export const inFlight: Set = new Set(); function toICEServers(str: string) { @@ -86,7 +95,7 @@ async function handleDeviceSocketRequest( `[Device] Device ${device.id} already connected. Terminating existing connection.`, ); - const [existingDeviceWs] = activeConnections.get(device.id)!; + const existingDeviceWs = activeConnections.get(device.id)!.ws; await new Promise(resolve => { console.log("[Device] Waiting for existing connection to close..."); existingDeviceWs.on("close", () => { @@ -148,12 +157,17 @@ function setupDeviceWebSocket(deviceWs: WebSocket, device: Device, req: Incoming (process.env.REAL_IP_HEADER && req.headers[process.env.REAL_IP_HEADER]) || req.socket.remoteAddress; - const deviceVersion = req.headers["x-app-version"] as string | null; + const deviceVersion = (req.headers["x-app-version"] as string | undefined) || null; + const rawSku = req.headers["x-device-sku"]; + const deviceSku = normalizeSku(rawSku); + if (rawSku && !deviceSku) { + console.log(`[Device] ${id} reported unknown SKU ${JSON.stringify(rawSku)}; ignoring`); + } // Store the connection - activeConnections.set(id, [deviceWs, `${ip}`, deviceVersion || null]); + activeConnections.set(id, { ws: deviceWs, ip: `${ip}`, version: deviceVersion, sku: deviceSku }); console.log( - `[Device] New connection for device ${id}, with version ${deviceVersion || "unknown"}`, + `[Device] New connection for device ${id}, with version ${deviceVersion || "unknown"} and SKU ${deviceSku || "unknown"}`, ); // Setup ping/pong for connection health checks @@ -289,7 +303,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri return clientWs.close(); } - const [deviceWs, ip, version] = deviceConn; + const { ws: deviceWs, ip, version, sku } = deviceConn; // If there's an active connection with this device, prevent a new one if (inFlight.has(deviceId)) { @@ -307,7 +321,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri clientWs.send( JSON.stringify({ type: "device-metadata", - data: { deviceVersion: version }, + data: { deviceVersion: version, deviceSku: effectiveSku(sku) }, }), ); diff --git a/src/webrtc.ts b/src/webrtc.ts index 06cc2a0..1d146de 100644 --- a/src/webrtc.ts +++ b/src/webrtc.ts @@ -30,14 +30,13 @@ export const CreateSession = async (req: express.Request, res: express.Response) ); } - const wsTuple = activeConnections.get(id); - if (!wsTuple) { + const deviceConn = activeConnections.get(id); + if (!deviceConn) { console.log("No socket for id", id); throw new NotFoundError(`No socket for id found`, "kvm_socket_not_found"); } - // extract the websocket and ip from the tuple - const [ws, ip] = wsTuple; + const { ws, ip } = deviceConn; let timeout: NodeJS.Timeout | undefined; diff --git a/test/devices.test.ts b/test/devices.test.ts new file mode 100644 index 0000000..307105d --- /dev/null +++ b/test/devices.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Request, Response } from "express"; + +import { testPrisma } from "./setup"; + +// webrtc-signaling pulls in src/index.ts (the listening server). Devices only +// need the connection map, so stub the module with an empty one. +const activeConnections = new Map(); +vi.mock("../src/webrtc-signaling", () => ({ activeConnections })); + +const { List, Retrieve, Token } = await import("../src/devices"); + +const GOOGLE_ID = "devices-test-google-id"; +const DEVICE_ID = "devices-test-device"; +const TEMP_TOKEN = "devices-test-temp-token"; + +function unsignedJwt(payload: Record): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.`; +} + +function tokenRequest(body: Record): Request { + return { body } as unknown as Request; +} + +function sessionRequest(params: Record = {}): Request { + return { + params, + session: { id_token: unsignedJwt({ iss: "https://accounts.google.com", sub: GOOGLE_ID }) }, + } as unknown as Request; +} + +function mockResponse(): Response & { _json: any } { + const res = { + _json: null, + status: vi.fn(function (this: any) { + return this; + }), + json: vi.fn(function (this: any, data: any) { + this._json = data; + return this; + }), + }; + return res as unknown as Response & { _json: any }; +} + +describe("device live state", () => { + beforeEach(async () => { + activeConnections.clear(); + const user = await testPrisma.user.upsert({ + where: { googleId: GOOGLE_ID }, + update: {}, + create: { googleId: GOOGLE_ID }, + }); + await testPrisma.device.create({ + data: { + id: DEVICE_ID, + userId: user.id, + tempToken: TEMP_TOKEN, + tempTokenExpiresAt: new Date(Date.now() + 60_000), + }, + }); + }); + + afterEach(async () => { + await testPrisma.device.deleteMany({ where: { id: DEVICE_ID } }); + await testPrisma.user.deleteMany({ where: { googleId: GOOGLE_ID } }); + }); + + it("issues a secret token and consumes the temp token", async () => { + const res = mockResponse(); + await Token(tokenRequest({ tempToken: TEMP_TOKEN }), res); + + expect(res._json.secretToken).toMatch(/^[0-9a-f]{40}$/); + const device = await testPrisma.device.findUniqueOrThrow({ where: { id: DEVICE_ID } }); + expect(device.tempToken).toBeNull(); + }); + + it("reports no version or SKU for an offline device", async () => { + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ + id: DEVICE_ID, + online: false, + version: null, + sku: null, + }); + }); + + it("treats an online device that sent no SKU as the original hardware", async () => { + activeConnections.set(DEVICE_ID, { ws: {}, ip: "10.0.0.2", version: "0.5.9", sku: null }); + + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ online: true, version: "0.5.9", sku: "jetkvm-v2" }); + }); + + it("reports the SKU and version from the live connection", async () => { + activeConnections.set(DEVICE_ID, { + ws: {}, + ip: "10.0.0.2", + version: "1.2.3", + sku: "jetkvm-mini-ethernet", + }); + + const res = mockResponse(); + await Retrieve(sessionRequest({ id: DEVICE_ID }), res); + + expect(res._json.device).toMatchObject({ + sku: "jetkvm-mini-ethernet", + online: true, + version: "1.2.3", + }); + }); + + it("lists devices with the same live state", async () => { + activeConnections.set(DEVICE_ID, { + ws: {}, + ip: "10.0.0.2", + version: "1.2.3", + sku: "jetkvm-mini-wireless", + }); + + const res = mockResponse(); + await List(sessionRequest(), res); + + expect(res._json.devices).toEqual([ + expect.objectContaining({ + id: DEVICE_ID, + online: true, + version: "1.2.3", + sku: "jetkvm-mini-wireless", + }), + ]); + }); +}); diff --git a/test/releases.test.ts b/test/releases.test.ts index 62e5111..cc32725 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -15,10 +15,14 @@ import { RetrieveLatestSystemRecovery, clearCaches, } from "../src/releases"; +import { legacyCompatibleSkus, otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; const DEFAULT_SKU = "jetkvm-v2"; const SDMMC_SKU = "jetkvm-v2-sdmmc"; -type ReleaseType = "app" | "system"; +const MINI_ETHERNET_SKU = "jetkvm-mini-ethernet"; +const MINI_WIRELESS_SKU = "jetkvm-mini-wireless"; // Helper to create mock Request function createMockRequest(query: Record = {}): Request { @@ -55,7 +59,7 @@ function createMockResponse(): Response & { } // Mock S3 responses for listing versions -function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { +function mockS3ListVersions(prefix: ReleaseType, versions: string[]) { s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/` }).resolves({ CommonPrefixes: versions.map(v => ({ Prefix: `${prefix}/${v}/` })), }); @@ -63,12 +67,12 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { // Mock S3 hash file response for legacy versions (no SKU support) function mockS3HashFile( - prefix: "app" | "system", + prefix: ReleaseType, version: string, hash: string, opts?: { hasSig?: boolean }, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const artifactPath = `${prefix}/${version}/${fileName}`; // Mock versionHasSkuSupport to return false (no SKU folders) @@ -89,13 +93,13 @@ function mockS3HashFile( // Mock S3 for versions with SKU support function mockS3SkuVersion( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, hash: string, opts?: { hasSig?: boolean }, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; // Mock versionHasSkuSupport to return true (has SKU folders) @@ -119,7 +123,7 @@ function mockS3SkuVersion( // Mock S3 for legacy version with file content (for redirect endpoints with hash verification) function mockS3LegacyVersionWithContent( - prefix: "app" | "system", + prefix: ReleaseType, version: string, fileName: string, content: string, @@ -130,6 +134,9 @@ function mockS3LegacyVersionWithContent( Contents: [], }); + // Legacy artifact exists (HeadObjectCommand for the existence check) + s3Mock.on(HeadObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({}); + // Mock legacy file path with content s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({ Body: createAsyncIterable(content) as any, @@ -143,7 +150,7 @@ function mockS3LegacyVersionWithContent( // Mock S3 for SKU version with file content (for redirect endpoints with hash verification) function mockS3SkuVersionWithContent( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, fileName: string, @@ -172,12 +179,12 @@ function mockS3SkuVersionWithContent( } function artifactFileName(type: ReleaseType) { - return type === "app" ? "jetkvm_app" : "system.tar"; + return otaFileForPrefix(type); } function artifactPath(type: ReleaseType, version: string, sku = DEFAULT_SKU) { const fileName = artifactFileName(type); - if (sku === DEFAULT_SKU) { + if (legacyCompatibleSkus(type).includes(sku)) { return `${type}/${version}/${fileName}`; } return `${type}/${version}/skus/${sku}/${fileName}`; @@ -263,6 +270,26 @@ describe("Retrieve handler", () => { }); }); + describe("SKU validation", () => { + it("rejects a SKU that is not registered before touching S3 or the DB", async () => { + const res = createMockResponse(); + await expect( + Retrieve(createMockRequest({ deviceId: "x", sku: "jetkvm-v3" }), res), + ).rejects.toThrow(BadRequestError); + await expect( + Retrieve(createMockRequest({ deviceId: "x", sku: "jetkvm-v3" }), res), + ).rejects.toThrow('Unknown SKU "jetkvm-v3"'); + expect(s3Mock.calls().length).toBe(0); + }); + + it("rejects an unregistered SKU on the app redirect", async () => { + const res = createMockResponse(); + await expect(RetrieveLatestApp(createMockRequest({ sku: "jetkvm-v3" }), res)).rejects.toThrow( + 'Unknown SKU "jetkvm-v3"', + ); + }); + }); + describe("S3 error handling", () => { it("should throw NotFoundError when no versions exist in S3", async () => { const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); @@ -399,10 +426,16 @@ describe("Retrieve handler", () => { res, ); - expect(jsonBody(res)).toMatchObject({ + // Exact shape: the response carries the wire fields only, never the + // resolver's internal bookkeeping (cache timestamps, semver ranges). + expect(jsonBody(res)).toEqual({ appVersion: "3.1.0", + appUrl: artifactUrl("app", "3.1.0"), + appHash: "app-3.1.0-jetkvm-v2-hash", appSigUrl: `${artifactUrl("app", "3.1.0")}.sig`, systemVersion: "3.0.0", + systemUrl: artifactUrl("system", "3.0.0"), + systemHash: "system-3.0.0-jetkvm-v2-hash", systemSigUrl: `${artifactUrl("system", "3.0.0")}.sig`, }); }); @@ -613,7 +646,7 @@ describe("Retrieve handler", () => { const req = createMockRequest({ deviceId: "device-sku-sig", prerelease: "true", - sku: "jetkvm-2", + sku: SDMMC_SKU, appVersion: "^8.0.0", systemVersion: "^8.0.0", }); @@ -621,22 +654,149 @@ 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", { + mockS3SkuVersion("app", "8.0.0", SDMMC_SKU, "sku-sig-app-hash", { hasSig: true }); + mockS3SkuVersion("system", "8.0.0", SDMMC_SKU, "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", + "https://cdn.test.com/app/8.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app.sig", ); expect(res._json.systemSigUrl).toBe( - "https://cdn.test.com/system/8.0.0/skus/jetkvm-2/system.tar.sig", + "https://cdn.test.com/system/8.0.0/skus/jetkvm-v2-sdmmc/system.tar.sig", ); }); }); + describe("mini product (one over-the-air artifact)", () => { + function miniArtifacts(version: string) { + return [ + releaseArtifact("mini", version, MINI_ETHERNET_SKU), + releaseArtifact("mini", version, MINI_WIRELESS_SKU), + ]; + } + + it("serves the mini firmware as the system artifact and omits the app fields", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.0.0", + systemUrl: artifactUrl("mini", "1.0.0", MINI_ETHERNET_SKU), + systemHash: "mini-1.0.0-jetkvm-mini-ethernet-hash", + }); + }); + + it("selects the artifact for the wireless variant", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-wireless", sku: MINI_WIRELESS_SKU }), res); + + expect(jsonBody(res)).toMatchObject({ + systemUrl: artifactUrl("mini", "1.0.0", MINI_WIRELESS_SKU), + systemHash: "mini-1.0.0-jetkvm-mini-wireless-hash", + }); + }); + + it("ignores JetKVM app/system releases and honours mini rollout state", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + await createDbRelease("mini", "1.1.0", 0, miniArtifacts("1.1.0")); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + // Seed data has app/system 1.2.0 in rollout; none of it leaks into the + // mini offer, and the 0% mini release is held back. + expect(jsonBody(res)).toMatchObject({ systemVersion: "1.0.0" }); + expect(jsonBody(res)).not.toHaveProperty("appVersion"); + }); + + it("honours a systemVersion constraint and ignores appVersion", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + await createDbRelease("mini", "1.1.0", 0, miniArtifacts("1.1.0")); + + const res = createMockResponse(); + await Retrieve( + createMockRequest({ + deviceId: "mini-ethernet", + sku: MINI_ETHERNET_SKU, + systemVersion: "1.1.0", + appVersion: "9.9.9", + }), + res, + ); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.1.0", + systemUrl: artifactUrl("mini", "1.1.0", MINI_ETHERNET_SKU), + systemHash: "mini-1.1.0-jetkvm-mini-ethernet-hash", + }); + }); + + it("includes systemSigUrl from the mini artifact's .sig sibling", async () => { + await createDbRelease("mini", "1.0.0", 100, miniArtifacts("1.0.0")); + mockArtifactSig("mini", "1.0.0", MINI_ETHERNET_SKU); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res); + + expect(jsonBody(res)).toMatchObject({ + systemSigUrl: `${artifactUrl("mini", "1.0.0", MINI_ETHERNET_SKU)}.sig`, + }); + expect(jsonBody(res)).not.toHaveProperty("appSigUrl"); + }); + + it("reads prereleases from the mini/ prefix only", async () => { + mockS3ListVersions("mini", ["1.0.0", "1.1.0-beta.1"]); + mockS3SkuVersion("mini", "1.1.0-beta.1", MINI_ETHERNET_SKU, "mini-beta-hash", { + hasSig: true, + }); + + const res = createMockResponse(); + await Retrieve( + createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU, prerelease: "true" }), + res, + ); + + expect(jsonBody(res)).toEqual({ + systemVersion: "1.1.0-beta.1", + systemUrl: `https://cdn.test.com/mini/1.1.0-beta.1/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin`, + systemHash: "mini-beta-hash", + systemSigUrl: `https://cdn.test.com/mini/1.1.0-beta.1/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin.sig`, + }); + // The app/ and system/ prefixes were never listed. + expect( + s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "app/" }).length + + s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "system/" }).length, + ).toBe(0); + }); + + it("never serves a pre-SKU mini layout", async () => { + mockS3ListVersions("mini", ["1.0.0"]); + mockS3HashFile("mini", "1.0.0", "legacy-mini-hash"); + + const res = createMockResponse(); + await expect( + Retrieve( + createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU, prerelease: "true" }), + res, + ), + ).rejects.toThrow('Version 1.0.0 has no artifact for SKU "jetkvm-mini-ethernet"'); + }); + + it("fails when no mini release exists for the SKU", async () => { + const res = createMockResponse(); + await expect( + Retrieve(createMockRequest({ deviceId: "mini-ethernet", sku: MINI_ETHERNET_SKU }), res), + ).rejects.toThrow(NotFoundError); + }); + }); + describe("S3 non-NotFoundError handling", () => { it("should wrap non-NotFoundError in InternalServerError", async () => { const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); @@ -772,25 +932,6 @@ describe("RetrieveLatestApp S3 redirect handler", () => { ); }); - it("should throw InternalServerError when hash does not match", async () => { - const req = createMockRequest({}); - const res = createMockResponse(); - - s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ - CommonPrefixes: [{ Prefix: "app/1.0.0/" }], - }); - - mockS3LegacyVersionWithContent( - "app", - "1.0.0", - "jetkvm_app", - "actual-content", - "wrong-hash-value", - ); - - await expect(RetrieveLatestApp(req, res)).rejects.toThrow(InternalServerError); - }); - it("should throw NotFoundError when app file is missing", async () => { const req = createMockRequest({}); const res = createMockResponse(); @@ -804,12 +945,9 @@ describe("RetrieveLatestApp S3 redirect handler", () => { Contents: [], }); - s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app" }).resolves({ - Body: undefined, - }); - s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app.sha256" }).resolves({ - Body: createAsyncIterable("some-hash") as any, - }); + s3Mock + .on(HeadObjectCommand, { Key: "app/1.0.0/jetkvm_app" }) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); }); @@ -860,7 +998,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); it("should throw NotFoundError when non-default SKU requested on legacy version", async () => { - const req = createMockRequest({ sku: "jetkvm-2" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ @@ -873,11 +1011,11 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); - await expect(RetrieveLatestApp(req, res)).rejects.toThrow("predates SKU support"); + await expect(RetrieveLatestApp(req, res)).rejects.toThrow("has no artifact for SKU"); }); it("redirects to the requested SKU path when the S3 version has SKU support", async () => { - const req = createMockRequest({ sku: "jetkvm-2" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ @@ -891,7 +1029,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { mockS3SkuVersionWithContent( "app", "2.0.0", - "jetkvm-2", + SDMMC_SKU, "jetkvm_app", content, hash, @@ -901,7 +1039,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { 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-v2-sdmmc/jetkvm_app", ); }); @@ -935,19 +1073,19 @@ describe("RetrieveLatestApp S3 redirect handler", () => { }); it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => { - const req = createMockRequest({ sku: "jetkvm-3" }); + const req = createMockRequest({ sku: SDMMC_SKU }); const res = createMockResponse(); s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({ CommonPrefixes: [{ Prefix: "app/2.0.0/" }], }); - // Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't + // Version has SKU support (jetkvm-v2 exists) but the SDMMC SKU 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(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }) + .on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app" }) .rejects({ name: "NoSuchKey", $metadata: { httpStatusCode: 404 }, @@ -955,7 +1093,7 @@ describe("RetrieveLatestApp S3 redirect handler", () => { await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestApp(req, res)).rejects.toThrow( - "is not available for version", + "has no artifact for SKU", ); }); }); @@ -1025,23 +1163,41 @@ describe("RetrieveLatestApp S3 redirect handler", () => { mockS3SkuVersionWithContent( "app", "2.0.0", - "jetkvm-2", + SDMMC_SKU, "jetkvm_app", content, hash, ); - const req2 = createMockRequest({ sku: "jetkvm-2" }); + const req2 = createMockRequest({ sku: SDMMC_SKU }); const res2 = createMockResponse(); await RetrieveLatestApp(req2, res2); expect(res2._redirectUrl).toBe( - "https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app", + "https://cdn.test.com/app/2.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app", ); }); }); }); +describe("RetrieveLatestApp for the mini product", () => { + beforeEach(() => { + clearCaches(); + s3Mock.reset(); + }); + + it("rejects mini SKUs, which have no app artifact, before touching S3", async () => { + const res = createMockResponse(); + await expect( + RetrieveLatestApp(createMockRequest({ sku: MINI_WIRELESS_SKU }), res), + ).rejects.toThrow(BadRequestError); + await expect( + RetrieveLatestApp(createMockRequest({ sku: MINI_WIRELESS_SKU }), res), + ).rejects.toThrow('SKU "jetkvm-mini-wireless" has no app artifact'); + expect(s3Mock.calls().length).toBe(0); + }); +}); + describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { beforeEach(() => { s3Mock.reset(); @@ -1129,28 +1285,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { ); }); - it("should throw InternalServerError when hash does not match", async () => { - const req = createMockRequest({}); - const res = createMockResponse(); - - s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ - CommonPrefixes: [{ Prefix: "system/1.0.0/" }], - }); - - mockS3LegacyVersionWithContent( - "system", - "1.0.0", - "update.img", - "actual-content", - "mismatched-hash", - ); - - await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - InternalServerError, - ); - }); - - it("should throw NotFoundError when recovery image or hash file is missing", async () => { + it("should throw NotFoundError when recovery image is missing", async () => { const req = createMockRequest({}); const res = createMockResponse(); @@ -1163,12 +1298,9 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { Contents: [], }); - s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img" }).resolves({ - Body: undefined, - }); - s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img.sha256" }).resolves({ - Body: undefined, - }); + s3Mock + .on(HeadObjectCommand, { Key: "system/1.0.0/update.img" }) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); }); @@ -1233,7 +1365,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - "predates SKU support", + "has no artifact for SKU", ); }); @@ -1281,7 +1413,74 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { BadRequestError, ); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - 'Unsupported SKU "jetkvm-future"', + 'Unknown SKU "jetkvm-future"', + ); + }); + + it("redirects a mini SKU to the full-flash image under the mini prefix", async () => { + s3Mock.on(ListObjectsV2Command, { Prefix: "mini/" }).resolves({ + CommonPrefixes: [{ Prefix: "mini/1.0.0/" }, { Prefix: "mini/1.1.0/" }], + }); + + const content = "mini-full-flash"; + const crypto = await import("crypto"); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + mockS3SkuVersionWithContent( + "mini", + "1.1.0", + MINI_WIRELESS_SKU, + "jetkvm-mini-full.bin", + content, + hash, + ); + + const res = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: MINI_WIRELESS_SKU }), res); + + expect(res.redirect).toHaveBeenCalledWith( + 302, + `https://cdn.test.com/mini/1.1.0/skus/${MINI_WIRELESS_SKU}/jetkvm-mini-full.bin`, + ); + expect(s3Mock.commandCalls(ListObjectsV2Command, { Prefix: "system/" }).length).toBe(0); + }); + + it("keeps separate recovery cache entries for JetKVM and mini SKUs", async () => { + s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({ + CommonPrefixes: [{ Prefix: "system/2.0.0/" }], + }); + s3Mock.on(ListObjectsV2Command, { Prefix: "mini/" }).resolves({ + CommonPrefixes: [{ Prefix: "mini/1.0.0/" }], + }); + const crypto = await import("crypto"); + const systemContent = "system-recovery"; + const miniContent = "mini-full-flash"; + mockS3SkuVersionWithContent( + "system", + "2.0.0", + DEFAULT_SKU, + "update.img", + systemContent, + crypto.createHash("sha256").update(systemContent).digest("hex"), + ); + mockS3SkuVersionWithContent( + "mini", + "1.0.0", + MINI_ETHERNET_SKU, + "jetkvm-mini-full.bin", + miniContent, + crypto.createHash("sha256").update(miniContent).digest("hex"), + ); + + const systemRes = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: DEFAULT_SKU }), systemRes); + const miniRes = createMockResponse(); + await RetrieveLatestSystemRecovery(createMockRequest({ sku: MINI_ETHERNET_SKU }), miniRes); + + expect(systemRes._redirectUrl).toBe( + "https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img", + ); + expect(miniRes._redirectUrl).toBe( + `https://cdn.test.com/mini/1.0.0/skus/${MINI_ETHERNET_SKU}/jetkvm-mini-full.bin`, ); }); @@ -1338,7 +1537,7 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError); await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow( - "is not available for version", + "has no artifact for SKU", ); }); }); diff --git a/test/setup.ts b/test/setup.ts index 8f95d77..8769ab2 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -3,6 +3,9 @@ import { mockClient } from "aws-sdk-client-mock"; import { S3Client } from "@aws-sdk/client-s3"; import { PrismaClient } from "@prisma/client"; import { config } from "dotenv"; +import { otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; // Load .env.development for config config({ path: ".env.development" }); @@ -24,8 +27,6 @@ export const s3Mock = mockClient(S3Client); // Create a test Prisma client export const testPrisma = new PrismaClient(); -type ReleaseType = "app" | "system"; - // Pre-SKU artifacts are jetkvm-v2 only; future SKUs need explicit // skus// uploads, registered via scripts/sync-releases.ts. const LEGACY_COMPATIBLE_SKUS = ["jetkvm-v2"]; @@ -141,7 +142,7 @@ export async function setRollout( version, type, rolloutPercentage: percentage, - url: `https://cdn.test.com/${type}/${version}/${type === "app" ? "jetkvm_app" : "system.tar"}`, + url: `https://cdn.test.com/${type}/${version}/${otaFileForPrefix(type)}`, hash: `test-hash-${version}-${type}`, }, }); diff --git a/test/skus.test.ts b/test/skus.test.ts new file mode 100644 index 0000000..6152ef4 --- /dev/null +++ b/test/skus.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_SKU, + KNOWN_SKUS, + OTA_PREFIXES, + artifactFor, + effectiveSku, + isKnownSku, + legacyCompatibleSkus, + normalizeSku, + otaArtifacts, + otaFileForPrefix, + skusForPrefix, +} from "../src/skus"; + +const JETKVM_SKUS = ["jetkvm-v2", "jetkvm-v2-sdmmc"]; +const MINI_SKUS = ["jetkvm-mini-ethernet", "jetkvm-mini-wireless"]; + +describe("SKU table", () => { + it("registers both JetKVM variants first, then both Mini variants", () => { + expect(KNOWN_SKUS).toEqual([...JETKVM_SKUS, ...MINI_SKUS]); + expect(KNOWN_SKUS[0]).toBe(DEFAULT_SKU); + }); + + it("gives both JetKVM variants the same two over-the-air artifacts", () => { + for (const sku of JETKVM_SKUS) { + expect(otaArtifacts(sku)).toEqual([ + { kind: "app", prefix: "app", file: "jetkvm_app" }, + { kind: "system", prefix: "system", file: "system.tar" }, + ]); + } + }); + + it("gives both Mini variants one over-the-air artifact, the firmware image as system", () => { + for (const sku of MINI_SKUS) { + expect(otaArtifacts(sku)).toEqual([ + { kind: "system", prefix: "mini", file: "jetkvm-mini.bin" }, + ]); + expect(artifactFor(sku, "app")).toBeUndefined(); + } + }); + + it("stores each product's recovery image next to what it is built with", () => { + expect(artifactFor("jetkvm-v2", "recovery")).toEqual({ + prefix: "system", + file: "update.img", + }); + expect(artifactFor("jetkvm-v2-sdmmc", "recovery")).toEqual({ + prefix: "system", + file: "update_sd.img.zip", + }); + for (const sku of MINI_SKUS) { + expect(artifactFor(sku, "recovery")).toEqual({ + prefix: "mini", + file: "jetkvm-mini-full.bin", + }); + } + }); + + it("syncs three prefixes, each holding one over-the-air file (recovery is not synced)", () => { + expect(OTA_PREFIXES).toEqual(["app", "system", "mini"]); + expect(otaFileForPrefix("app")).toBe("jetkvm_app"); + expect(otaFileForPrefix("system")).toBe("system.tar"); + expect(otaFileForPrefix("mini")).toBe("jetkvm-mini.bin"); + }); + + it("lists the variants that consume each prefix", () => { + expect(skusForPrefix("app")).toEqual(JETKVM_SKUS); + expect(skusForPrefix("system")).toEqual(JETKVM_SKUS); + expect(skusForPrefix("mini")).toEqual(MINI_SKUS); + }); + + it("only allows the original hardware on the pre-SKU layout, and no mini at all", () => { + expect(legacyCompatibleSkus("app")).toEqual([DEFAULT_SKU]); + expect(legacyCompatibleSkus("system")).toEqual([DEFAULT_SKU]); + expect(legacyCompatibleSkus("mini")).toEqual([]); + }); + + it("rejects SKUs that are not registered, including prototype keys", () => { + expect(isKnownSku("jetkvm-mini-lte")).toBe(false); + expect(isKnownSku("__proto__")).toBe(false); + expect(isKnownSku("constructor")).toBe(false); + expect(() => artifactFor("jetkvm-mini-lte", "system")).toThrow( + 'Unknown SKU "jetkvm-mini-lte"', + ); + }); +}); + +describe("device-reported SKUs", () => { + it("normalizes a reported SKU to a registered one or null", () => { + expect(normalizeSku("jetkvm-mini-ethernet")).toBe("jetkvm-mini-ethernet"); + expect(normalizeSku("jetkvm-mini-lte")).toBeNull(); + expect(normalizeSku(["jetkvm-v2"])).toBeNull(); + expect(normalizeSku(undefined)).toBeNull(); + expect(normalizeSku("")).toBeNull(); + expect(normalizeSku("__proto__")).toBeNull(); + }); + + it("treats a device without a stored SKU as the original hardware", () => { + expect(effectiveSku(null)).toBe(DEFAULT_SKU); + expect(effectiveSku(undefined)).toBe(DEFAULT_SKU); + expect(effectiveSku("jetkvm-mini-ethernet")).toBe("jetkvm-mini-ethernet"); + }); +}); diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 68267e1..c0f1a92 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -7,22 +7,27 @@ import { import { describe, expect, beforeEach, it } from "vitest"; import { collectReleaseArtifacts, syncReleases } from "../scripts/sync-releases"; +import { otaFileForPrefix } from "../src/skus"; + +type ReleaseType = string; import { createAsyncIterable, s3Mock, testPrisma } from "./setup"; const DEFAULT_SKU = "jetkvm-v2"; const SDMMC_SKU = "jetkvm-v2-sdmmc"; +const MINI_ETHERNET_SKU = "jetkvm-mini-ethernet"; +const MINI_WIRELESS_SKU = "jetkvm-mini-wireless"; const SYNC_BUCKET = "test-bucket"; const SYNC_BASE_URL = "https://cdn.test.com"; const syncS3Client = new S3Client({}); -function mockS3ListVersions(prefix: "app" | "system", versions: string[]) { +function mockS3ListVersions(prefix: ReleaseType, 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"; +function mockS3HashFile(prefix: ReleaseType, version: string, hash: string) { + const fileName = otaFileForPrefix(prefix); s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ Contents: [], }); @@ -34,12 +39,12 @@ function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) } function mockS3SkuVersion( - prefix: "app" | "system", + prefix: ReleaseType, version: string, sku: string, hash: string, ) { - const fileName = prefix === "app" ? "jetkvm_app" : "system.tar"; + const fileName = otaFileForPrefix(prefix); const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`; s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({ @@ -116,6 +121,46 @@ describe("sync-releases script", () => { ]); }); + it("collects mini artifacts for the mini SKUs only", async () => { + mockS3SkuVersion("mini", "1.0.0", MINI_ETHERNET_SKU, "mini-ethernet-hash"); + mockS3SkuVersion("mini", "1.0.0", MINI_WIRELESS_SKU, "mini-wireless-hash"); + // A stray JetKVM upload under mini/ must not be picked up. + mockS3SkuVersion("mini", "1.0.0", DEFAULT_SKU, "stray-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "mini", + "1.0.0", + ); + + expect(artifacts).toEqual([ + { + url: `https://cdn.test.com/mini/1.0.0/skus/${MINI_ETHERNET_SKU}/jetkvm-mini.bin`, + hash: "mini-ethernet-hash", + compatibleSkus: [MINI_ETHERNET_SKU], + }, + { + url: `https://cdn.test.com/mini/1.0.0/skus/${MINI_WIRELESS_SKU}/jetkvm-mini.bin`, + hash: "mini-wireless-hash", + compatibleSkus: [MINI_WIRELESS_SKU], + }, + ]); + }); + + it("ignores a mini version uploaded without the skus/ layout", async () => { + mockS3HashFile("mini", "1.0.1", "legacy-mini-hash"); + + const artifacts = await collectReleaseArtifacts( + { s3Client: syncS3Client }, + { bucketName: SYNC_BUCKET, baseUrl: SYNC_BASE_URL }, + "mini", + "1.0.1", + ); + + expect(artifacts).toEqual([]); + }); + it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { const version = "9.9.4"; @@ -133,6 +178,7 @@ describe("sync-releases script", () => { mockS3ListVersions("app", [version, "10.0.0-beta.1"]); mockS3ListVersions("system", [version]); + mockS3ListVersions("mini", []); mockS3HashFile("app", version, "app-hash"); mockS3SkuVersion("system", version, DEFAULT_SKU, "system-hash-v2"); mockS3SkuVersion("system", version, SDMMC_SKU, "system-hash-sdmmc");