Release dev → main (#60)

* feat(sync-releases): show artifact details, verify GPG, allow custom rollout (#58)

* feat(sync-releases): show artifact details, verify GPG, allow custom rollout

The previous production prompt was a single y/N with no artifact context, no
way to override the hardcoded 10% rollout, and no signature verification. An
operator confirming a release this way had to trust that the right files were
in S3 and that the .sig was issued by the OTA root key — neither was visible.

Changes:
* Print full artifact summary before the prompt: URL, sha256, compatibleSkus,
  and signature status for each artifact.
* Verify each .sig file with gpg --status-fd=1 and check the primary key
  fingerprint against OTA_ROOT_KEY_FPR (mirrored from rv1106-system's
  release_r2.sh). Reports valid / wrong-root / invalid / missing-pubkey /
  gpg-unavailable / absent, with a loud WARNING line for wrong-root and
  invalid signatures so the operator cannot miss them.
* Print the latest already-synced release of the same type before the prompt
  so the operator can confirm this is the next expected version.
* Add an interactive rollout-percentage prompt with 10% default, validated to
  0-100, replacing the hardcoded 10.
* Add an `a`/`abort` answer alongside y/N so operators can stop a multi-
  release sync mid-run when they spot something wrong, instead of having to
  N through every remaining version.
* Print the DB target and bucket as the first line of main() so a wrong
  .env.production selection is obvious before any prompts fire.
* Print a final run summary with counters: created / skipped-by-user /
  already-synced / no-artifacts, plus an "aborted at <type> <version>" line
  when the run was cut short.
* Add `npm run sync-releases:production` script and ignore .env.production.

Verification path runs only when NODE_ENV=production, so non-prod runs and
the test suite never spawn gpg or download artifacts. All 52 existing tests
still pass; tsc build is clean.

* refactor: hoist objectKeyFromArtifactUrl into helpers

Both src/releases.ts and scripts/sync-releases.ts had their own copy of the
same URL-to-S3-key conversion. Moved into src/helpers.ts and imported from
both call sites so a future change (e.g. CDN path prefix handling) only needs
to land once.

* fix(sync-releases): only treat ERRSIG rc=9 as missing pubkey

GnuPG's ERRSIG line carries an `rc` reason code. rc=9 is the only one that
means "we don't have the signer's key" — rc=4 (unsupported algorithm) and
other codes are real verification failures. The previous implementation
collapsed every ERRSIG into noPubkey, which would have falsely told the
operator the OTA root key was missing when the actual problem was e.g. an
unsupported pubkey algorithm.

Now parses the rc field and surfaces non-9 ERRSIG codes as `invalid` with a
human reason (rc=4 → "unsupported algorithm", others → "gpg error code N").

* feat(releases): map recovery artifact filename per SKU (#59)

The system recovery endpoint hard-coded `update.img`, which is the
eMMC/RKDevTool image. The SDMMC variant ships as `update_sd.img.zip`
(a balenaEtcher-flashable archive), so requesting recovery for the
`jetkvm-v2-sdmmc` SKU returned the wrong artifact (or 404'd once SKU
folders were enforced). Drive the filename from a small per-SKU map
and reject unmapped SKUs with 400 so a typo can't silently fall back
to the wrong image.
This commit is contained in:
Adam Shiervani
2026-04-29 09:53:15 +02:00
committed by GitHub
parent 412208b41f
commit 988a07d418
6 changed files with 520 additions and 34 deletions
+1
View File
@@ -3,3 +3,4 @@ node_modules
dist/
.env
.env.development
.env.production
+1
View File
@@ -12,6 +12,7 @@
"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",
"sync-releases:production": "NODE_ENV=production node -r ts-node/register --env-file=.env.production ./scripts/sync-releases.ts",
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
+450 -11
View File
@@ -1,3 +1,13 @@
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { stdin, stdout } from "node:process";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createInterface } from "node:readline/promises";
import {
GetObjectCommand,
HeadObjectCommand,
@@ -7,7 +17,9 @@ import {
import { PrismaClient } from "@prisma/client";
import semver from "semver";
import { streamToString } from "../src/helpers";
import { objectKeyFromArtifactUrl, streamToString } from "../src/helpers";
const OTA_ROOT_KEY_FPR = "AF5A36A993D828FEFE7C18C2D1B9856C26A79E95";
type ReleaseType = "app" | "system";
@@ -41,6 +53,372 @@ function legacyCompatibleSkus(): string[] {
return [DEFAULT_SKU];
}
const DEFAULT_ROLLOUT_PERCENTAGE = 10;
type ReleaseOutcome =
| "created"
| "already-synced"
| "no-artifacts"
| "skipped"
| "aborted";
type ReleaseDecision =
| { kind: "create"; rolloutPercentage: number }
| { kind: "skip" }
| { kind: "abort" };
interface LatestExistingRelease {
version: string;
rolloutPercentage: number;
}
type SignatureStatus =
| { kind: "absent" }
| { kind: "valid"; signingFpr: string; rootFpr: string }
| { kind: "wrong-root"; signingFpr: string; rootFpr: string }
| { kind: "invalid"; reason: string }
| { kind: "missing-pubkey"; rootFpr?: string }
| { kind: "gpg-unavailable" };
interface ArtifactDisplayInfo {
artifact: ReleaseArtifactInput;
signature: SignatureStatus;
}
function shortFpr(fpr: string): string {
// Keep the leading 16 hex chars (8 bytes) — enough to be unambiguous in a log
// line while staying readable. The full fingerprint is what we actually
// compare against; this is just for display.
return fpr.slice(0, 16);
}
function describeSignature(status: SignatureStatus): string {
switch (status.kind) {
case "absent":
return "NO (no .sig file in S3)";
case "valid":
return `yes (root ${shortFpr(status.rootFpr)})`;
case "wrong-root":
return `WRONG ROOT (got ${shortFpr(status.rootFpr)}, expected ${shortFpr(OTA_ROOT_KEY_FPR)})`;
case "invalid":
return `INVALID (${status.reason})`;
case "missing-pubkey":
return `cannot verify (OTA root key ${shortFpr(OTA_ROOT_KEY_FPR)} not in local GPG keyring)`;
case "gpg-unavailable":
return "cannot verify (gpg not installed)";
}
}
async function downloadObjectToFile(
s3Client: S3Client,
bucketName: string,
key: string,
destPath: string,
): Promise<void> {
const response = await s3Client.send(
new GetObjectCommand({ Bucket: bucketName, Key: key }),
);
if (!response.Body) {
throw new Error(`Empty body from S3 for key ${key}`);
}
await pipeline(response.Body as Readable, createWriteStream(destPath));
}
function runGpgVerify(
sigPath: string,
artifactPath: string,
): Promise<{ exitCode: number; statusOutput: string; stderrOutput: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(
"gpg",
["--batch", "--status-fd=1", "--verify", sigPath, artifactPath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let statusOutput = "";
let stderrOutput = "";
proc.stdout.on("data", chunk => (statusOutput += chunk.toString()));
proc.stderr.on("data", chunk => (stderrOutput += chunk.toString()));
proc.on("error", reject);
proc.on("close", exitCode => {
resolve({ exitCode: exitCode ?? -1, statusOutput, stderrOutput });
});
});
}
interface GpgStatus {
validSig?: { signingFpr: string; rootFpr: string };
noPubkey?: boolean;
// ERRSIG `rc` field. GnuPG documents rc=4 (unsupported algorithm),
// rc=9 (missing public key); other codes are possible and we leave
// them as raw strings for the caller to format.
errSigRc?: string;
badSig?: boolean;
}
const ERRSIG_RC_REASONS: Record<string, string> = {
"4": "unsupported algorithm",
"9": "missing public key",
};
function describeErrSigRc(rc: string): string {
return ERRSIG_RC_REASONS[rc] ?? `gpg error code ${rc}`;
}
function parseGpgStatus(statusOutput: string): GpgStatus {
const result: GpgStatus = {};
for (const rawLine of statusOutput.split("\n")) {
const line = rawLine.replace(/^\[GNUPG:\]\s+/, "").trim();
if (line.startsWith("VALIDSIG ")) {
// VALIDSIG <signing-fpr> <date> <ts> <expire> <ver> <pubkey-algo>
// <hash-algo> <sig-class> <primary-key-fpr>
// Fields are space-separated; index 10 is the primary key fingerprint.
const parts = line.split(/\s+/);
if (parts.length >= 11) {
result.validSig = { signingFpr: parts[1], rootFpr: parts[10] };
}
} else if (line.startsWith("NO_PUBKEY ")) {
result.noPubkey = true;
} else if (line.startsWith("ERRSIG ")) {
// ERRSIG <keyid> <pkalgo> <hashalgo> <sig_class> <time> <rc> [<fpr>]
// Index 6 is the rc field. Only rc=9 means "missing public key" —
// other codes (e.g. 4 = unsupported algorithm) are real verification
// failures and must not be reported as missing-pubkey.
const parts = line.split(/\s+/);
if (parts.length >= 7) {
result.errSigRc = parts[6];
}
} else if (line.startsWith("BADSIG ")) {
result.badSig = true;
}
}
return result;
}
async function verifySignature(
s3Client: S3Client,
bucketName: string,
artifactKey: string,
): Promise<SignatureStatus> {
const sigKey = `${artifactKey}.sig`;
if (!(await s3ObjectExists(s3Client, bucketName, sigKey))) {
return { kind: "absent" };
}
const dir = await mkdtemp(path.join(tmpdir(), "sync-releases-verify-"));
const sigPath = path.join(dir, "artifact.sig");
const artifactPath = path.join(dir, "artifact");
try {
await Promise.all([
downloadObjectToFile(s3Client, bucketName, sigKey, sigPath),
downloadObjectToFile(s3Client, bucketName, artifactKey, artifactPath),
]);
let result: Awaited<ReturnType<typeof runGpgVerify>>;
try {
result = await runGpgVerify(sigPath, artifactPath);
} catch (err: any) {
if (err?.code === "ENOENT") {
return { kind: "gpg-unavailable" };
}
throw err;
}
const parsed = parseGpgStatus(result.statusOutput);
if (parsed.badSig) {
return { kind: "invalid", reason: "BADSIG (signature does not match)" };
}
if (parsed.validSig) {
const rootFprUpper = parsed.validSig.rootFpr.toUpperCase();
if (rootFprUpper !== OTA_ROOT_KEY_FPR.toUpperCase()) {
return { kind: "wrong-root", ...parsed.validSig };
}
return { kind: "valid", ...parsed.validSig };
}
// NO_PUBKEY and ERRSIG rc=9 both mean "we don't have the signer's key".
// Any other ERRSIG rc is a real failure (e.g. unsupported algorithm) and
// must surface as `invalid`, not `missing-pubkey`, otherwise the prompt
// would falsely tell the operator to import a key they already have.
if (parsed.noPubkey || parsed.errSigRc === "9") {
return { kind: "missing-pubkey" };
}
if (parsed.errSigRc) {
return {
kind: "invalid",
reason: `ERRSIG ${parsed.errSigRc} (${describeErrSigRc(parsed.errSigRc)})`,
};
}
const stderrFirstLine =
result.stderrOutput.split("\n").find(l => l.trim().length > 0)?.trim() ??
`gpg exited ${result.exitCode}`;
return { kind: "invalid", reason: stderrFirstLine };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function loadArtifactDisplayInfo(
clients: Pick<SyncClients, "s3Client">,
config: SyncConfig,
artifacts: ReleaseArtifactInput[],
): Promise<ArtifactDisplayInfo[]> {
return Promise.all(
artifacts.map(async artifact => {
const signature = await verifySignature(
clients.s3Client,
config.bucketName,
objectKeyFromArtifactUrl(artifact.url),
);
return { artifact, signature };
}),
);
}
async function findLatestExistingRelease(
prisma: PrismaClient,
type: ReleaseType,
): Promise<LatestExistingRelease | null> {
const releases = await prisma.release.findMany({
where: { type },
select: { version: true, rolloutPercentage: true },
});
if (releases.length === 0) return null;
const latestVersion = semver.maxSatisfying(
releases.map(r => r.version),
"*",
{ includePrerelease: true },
);
if (!latestVersion) return null;
return releases.find(r => r.version === latestVersion) ?? null;
}
function printArtifactSummary(
type: ReleaseType,
version: string,
artifactInfos: ArtifactDisplayInfo[],
latestExisting: LatestExistingRelease | null,
): void {
console.log("");
console.log(
`[sync-releases] About to create production ${type} release ${version}:`,
);
if (latestExisting) {
console.log(
` latest existing: ${latestExisting.version} at ${latestExisting.rolloutPercentage}% rollout`,
);
} else {
console.log(` latest existing: (none — this will be the first ${type} release)`);
}
console.log(` artifacts (${artifactInfos.length}):`);
artifactInfos.forEach(({ artifact, signature }, index) => {
console.log(` [${index + 1}] url: ${artifact.url}`);
console.log(` hash: ${artifact.hash}`);
console.log(` skus: ${artifact.compatibleSkus.join(", ")}`);
console.log(` signed: ${describeSignature(signature)}`);
});
const warnings = artifactInfos.flatMap(({ signature }, index) => {
const label = `artifact [${index + 1}]`;
switch (signature.kind) {
case "wrong-root":
return [
`WARNING: ${label} signed by an UNTRUSTED root (got ${signature.rootFpr}, expected ${OTA_ROOT_KEY_FPR}). Devices that enforce the OTA root will reject this firmware.`,
];
case "invalid":
return [
`WARNING: ${label} signature is INVALID: ${signature.reason}. Do not publish unless you have verified this manually.`,
];
default:
return [];
}
});
if (warnings.length > 0) {
console.log("");
for (const warning of warnings) {
console.log(` ${warning}`);
}
}
console.log("");
}
async function promptRolloutPercentage(
readline: ReturnType<typeof createInterface>,
): Promise<number> {
while (true) {
const answer = (
await readline.question(
` Rollout percentage [${DEFAULT_ROLLOUT_PERCENTAGE}]: `,
)
).trim();
if (answer === "") {
return DEFAULT_ROLLOUT_PERCENTAGE;
}
const parsed = Number(answer);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
console.log(" Error: enter an integer between 0 and 100");
continue;
}
return parsed;
}
}
async function confirmProductionCreate(
clients: SyncClients,
config: SyncConfig,
type: ReleaseType,
version: string,
artifacts: ReleaseArtifactInput[],
): Promise<ReleaseDecision> {
if (process.env.NODE_ENV !== "production") {
return { kind: "create", rolloutPercentage: DEFAULT_ROLLOUT_PERCENTAGE };
}
if (!stdin.isTTY || !stdout.isTTY) {
throw new Error(
"Production release sync requires an interactive terminal for DB write confirmation.",
);
}
const [artifactInfos, latestExisting] = await Promise.all([
loadArtifactDisplayInfo(clients, config, artifacts),
findLatestExistingRelease(clients.prisma, type),
]);
printArtifactSummary(type, version, artifactInfos, latestExisting);
const readline = createInterface({ input: stdin, output: stdout });
try {
const rolloutPercentage = await promptRolloutPercentage(readline);
const confirmation = (
await readline.question(
` Create production ${type} release ${version} at ${rolloutPercentage}% rollout? [y/N/a (abort run)] `,
)
)
.trim()
.toLowerCase();
if (["a", "abort"].includes(confirmation)) {
return { kind: "abort" };
}
if (!["y", "yes"].includes(confirmation)) {
return { kind: "skip" };
}
return { kind: "create", rolloutPercentage };
} finally {
readline.close();
}
}
function isS3NotFound(error: any): boolean {
return (
error.name === "NotFound" ||
@@ -184,35 +562,52 @@ async function listStableVersions(
}
async function syncRelease(
prisma: PrismaClient,
clients: SyncClients,
config: SyncConfig,
type: ReleaseType,
version: string,
artifacts: ReleaseArtifactInput[],
): Promise<void> {
): Promise<ReleaseOutcome> {
if (artifacts.length === 0) {
console.log(`[sync-releases] ${type} ${version}: skipped, no compatible artifacts`);
return;
return "no-artifacts";
}
// 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({
const existing = await clients.prisma.release.findUnique({
where: { version_type: { version, type } },
select: { id: true },
});
if (existing) {
console.log(`[sync-releases] ${type} ${version}: already synced, skipping`);
return;
return "already-synced";
}
const decision = await confirmProductionCreate(
clients,
config,
type,
version,
artifacts,
);
if (decision.kind === "abort") {
console.log(`[sync-releases] ${type} ${version}: aborted by user`);
return "aborted";
}
if (decision.kind === "skip") {
console.log(`[sync-releases] ${type} ${version}: skipped by user`);
return "skipped";
}
const primaryArtifact = artifacts[0];
await prisma.release.create({
await clients.prisma.release.create({
data: {
version,
type,
rolloutPercentage: 10,
rolloutPercentage: decision.rolloutPercentage,
url: primaryArtifact.url,
hash: primaryArtifact.hash,
artifacts: {
@@ -226,25 +621,69 @@ async function syncRelease(
});
console.log(
`[sync-releases] ${type} ${version}: created with ${artifacts.length} artifact(s)`,
`[sync-releases] ${type} ${version}: created with ${artifacts.length} artifact(s) at ${decision.rolloutPercentage}% rollout`,
);
return "created";
}
export async function syncReleases(
clients: SyncClients,
config: SyncConfig,
): Promise<void> {
for (const type of ["app", "system"] as const) {
const stats: Record<ReleaseOutcome, number> = {
created: 0,
"already-synced": 0,
"no-artifacts": 0,
skipped: 0,
aborted: 0,
};
let abortedAt: { type: ReleaseType; version: string } | null = null;
outer: 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);
const outcome = await syncRelease(clients, config, type, version, artifacts);
stats[outcome]++;
if (outcome === "aborted") {
abortedAt = { type, version };
break outer;
}
}
}
if (abortedAt) {
console.log(
`[sync-releases] aborted at ${abortedAt.type} ${abortedAt.version}; remaining versions in this run were not processed`,
);
}
console.log(
`[sync-releases] done: created=${stats.created} skipped-by-user=${stats.skipped} already-synced=${stats["already-synced"]} no-artifacts=${stats["no-artifacts"]}`,
);
}
function describeDbTarget(): string {
const raw = process.env.DATABASE_URL;
if (!raw) return "(DATABASE_URL not set)";
try {
const parsed = new URL(raw);
const host = parsed.hostname || "?";
const port = parsed.port ? `:${parsed.port}` : "";
const dbName = parsed.pathname.replace(/^\/+/, "") || "?";
return `${host}${port}/${dbName}`;
} catch {
return "(unparseable DATABASE_URL)";
}
}
async function main(): Promise<void> {
console.log(
`[sync-releases] env=${process.env.NODE_ENV ?? "(unset)"} db=${describeDbTarget()} bucket=${process.env.R2_BUCKET ?? "(unset)"}`,
);
const prisma = new PrismaClient();
const s3Client = new S3Client({
endpoint: process.env.R2_ENDPOINT!,
+8
View File
@@ -56,4 +56,12 @@ export function getDeviceRolloutBucket(deviceId: string): number {
const hash = createHash("md5").update(deviceId).digest("hex");
const hashPrefix = hash.substring(0, 8);
return parseInt(hashPrefix, 16) % 100;
}
/**
* Extracts the S3 object key from an artifact URL like
* `https://cdn.example.com/app/0.5.0/jetkvm_app` → `app/0.5.0/jetkvm_app`.
*/
export function objectKeyFromArtifactUrl(artifactUrl: string): string {
return decodeURIComponent(new URL(artifactUrl).pathname.replace(/^\/+/, ""));
}
+21 -7
View File
@@ -13,6 +13,7 @@ import { LRUCache } from "lru-cache";
import {
getDeviceRolloutBucket,
objectKeyFromArtifactUrl,
streamToString,
toSemverRange,
verifyHash,
@@ -22,6 +23,18 @@ 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<string, string> = {
"jetkvm-v2": "update.img",
"jetkvm-v2-sdmmc": "update_sd.img.zip",
};
/** Query param schema builders for common patterns */
const queryString = () =>
z
@@ -388,11 +401,6 @@ function toRelease(
return release as Release;
}
function objectKeyFromArtifactUrl(artifactUrl: string): string {
const parsed = new URL(artifactUrl);
return decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
}
async function resolveSigUrlFromArtifactUrl(
artifactUrl: string,
): Promise<string | undefined> {
@@ -676,6 +684,11 @@ function releaseCacheKey(prefix: string, query: LatestQuery): string {
export const RetrieveLatestSystemRecovery = cachedRedirect(
query => releaseCacheKey("system-recovery", query),
async query => {
const recoveryArtifact = RECOVERY_ARTIFACT_BY_SKU[query.sku];
if (!recoveryArtifact) {
throw new BadRequestError(`Unsupported SKU "${query.sku}"`);
}
// Get the latest system recovery image from S3. It's stored in the system/ folder.
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
@@ -702,12 +715,13 @@ export const RetrieveLatestSystemRecovery = cachedRedirect(
throw new NotFoundError("No valid system recovery versions found");
}
// Resolve the artifact path with SKU support (using update.img for recovery)
// 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",
latestVersion,
query.sku,
"update.img",
recoveryArtifact,
);
const [firmwareFile, hashFile] = await Promise.all([
+39 -16
View File
@@ -1219,7 +1219,7 @@ describe("RetrieveLatestSystemRecovery 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: "system/" }).resolves({
@@ -1237,23 +1237,23 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
);
});
it("redirects to the requested SKU path when the S3 version has SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
it("redirects SDMMC SKU to update_sd.img.zip when the S3 version has SKU support", async () => {
const req = createMockRequest({ sku: SDMMC_SKU });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
const content = "sku-recovery-content";
const content = "sdmmc-recovery-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3SkuVersionWithContent(
"system",
"2.0.0",
"jetkvm-2",
"update.img",
SDMMC_SKU,
"update_sd.img.zip",
content,
hash,
);
@@ -1262,7 +1262,26 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
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/${SDMMC_SKU}/update_sd.img.zip`,
);
});
it("should throw BadRequestError for an unmapped SKU", async () => {
const req = createMockRequest({ sku: "jetkvm-future" });
const res = createMockResponse();
// Even though we never reach S3, mock the listing so a regression that
// accepted unknown SKUs would surface as a different kind of failure
// rather than silently returning an unrelated error from S3.
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(
BadRequestError,
);
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(
'Unsupported SKU "jetkvm-future"',
);
});
@@ -1295,20 +1314,23 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
);
});
it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-3" });
it("should throw NotFoundError when SDMMC zip missing on version with SKU support", async () => {
const req = createMockRequest({ sku: SDMMC_SKU });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/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 folder
// hasn't shipped update_sd.img.zip for this version yet.
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" })
.on(HeadObjectCommand, {
Key: `system/2.0.0/skus/${SDMMC_SKU}/update_sd.img.zip`,
})
.rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
@@ -1378,7 +1400,8 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
await RetrieveLatestSystemRecovery(req1, res1);
expect(res1._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img");
// Second call with different SKU should NOT use cached result
// Second call with the SDMMC SKU should NOT use the cached eMMC result;
// it must re-resolve and pick up the SDMMC zip path instead.
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
@@ -1386,18 +1409,18 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => {
mockS3SkuVersionWithContent(
"system",
"2.0.0",
"jetkvm-2",
"update.img",
SDMMC_SKU,
"update_sd.img.zip",
content,
hash,
);
const req2 = createMockRequest({ sku: "jetkvm-2" });
const req2 = createMockRequest({ sku: SDMMC_SKU });
const res2 = createMockResponse();
await RetrieveLatestSystemRecovery(req2, res2);
expect(res2._redirectUrl).toBe(
"https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img",
`https://cdn.test.com/system/2.0.0/skus/${SDMMC_SKU}/update_sd.img.zip`,
);
});
});