OTA for specific SKUs (#49)

This commit is contained in:
Adam Shiervani
2026-01-28 10:53:23 +01:00
committed by GitHub
parent 3e6acb65c3
commit 1958830b27
8 changed files with 913 additions and 89 deletions
+20 -1
View File
@@ -9,13 +9,29 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://jetkvm:jetkvm@localhost:5432/jetkvm?schema=public
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: jetkvm
POSTGRES_PASSWORD: jetkvm
POSTGRES_DB: jetkvm
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U jetkvm -d jetkvm"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
node-version: v22.21.0
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -35,6 +51,9 @@ jobs:
env:
CI: true
- name: Run Prisma Migrations
run: npx prisma migrate deploy
- name: Run Tests
run: npm test
+3
View File
@@ -50,6 +50,9 @@ npx prisma migrate deploy
# Start the production server on port 3000
npm run dev
# Run tests
npm test
```
## Production
+12 -2
View File
@@ -29,7 +29,8 @@
"semver": "^7.6.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"ws": "^8.17.1"
"ws": "^8.17.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/express": "^5.0.6",
@@ -41,7 +42,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": "21.1.0"
"node": "22.21.0"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
@@ -5086,6 +5087,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+3 -2
View File
@@ -16,7 +16,7 @@
"test:coverage": "vitest run --coverage"
},
"engines": {
"node": "21.1.0"
"node": "22.21.0"
},
"keywords": [],
"author": "JetKVM",
@@ -42,7 +42,8 @@
"semver": "^7.6.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5",
"ws": "^8.17.1"
"ws": "^8.17.1",
"zod": "^4.3.6"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
+12 -3
View File
@@ -40,7 +40,10 @@ export const List = async (req: express.Request, res: express.Response) => {
}
};
export const Retrieve = async (req: express.Request, res: express.Response) => {
export const Retrieve = async (
req: express.Request<{ id: string }>,
res: express.Response
) => {
const idToken = req.session?.id_token;
const { sub } = jose.decodeJwt(idToken);
const { id } = req.params;
@@ -55,7 +58,10 @@ export const Retrieve = async (req: express.Request, res: express.Response) => {
return res.status(200).json({ device });
};
export const Update = async (req: express.Request, res: express.Response) => {
export const Update = async (
req: express.Request<{ id: string }>,
res: express.Response
) => {
const idToken = req.session?.id_token;
const { sub } = jose.decodeJwt(idToken);
if (!sub) throw new UnauthorizedError("Missing sub in token");
@@ -94,7 +100,10 @@ export const Token = async (req: express.Request, res: express.Response) => {
return res.json({ secretToken });
};
export const Delete = async (req: express.Request, res: express.Response) => {
export const Delete = async (
req: express.Request<{ id: string }>,
res: express.Response
) => {
if (req.headers.authorization?.startsWith("Bearer ")) {
const secretToken = req.headers.authorization.split("Bearer ")[1];
+13 -1
View File
@@ -32,7 +32,9 @@ export async function verifyHash(
): Promise<boolean> {
const content = await streamToBuffer(file.Body);
const remoteHash = await streamToString(hashFile.Body);
const localHash = createHash("sha256").update(content).digest("hex");
const localHash = createHash("sha256")
.update(new Uint8Array(content))
.digest("hex");
const matches = remoteHash.trim() === localHash;
if (!matches && exception) {
@@ -44,4 +46,14 @@ export async function verifyHash(
export function toSemverRange(range?: string) {
if (!range) return "*";
return validRange(range) || "*";
}
/**
* Computes a deterministic rollout bucket (0-99) for a device ID.
* Used to decide if a device is eligible for a staged rollout.
*/
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;
}
+240 -62
View File
@@ -1,13 +1,83 @@
import { Request, Response } from "express";
import { prisma } from "./db";
import { BadRequestError, InternalServerError, NotFoundError } from "./errors";
import { createHash } from "crypto";
import semver from "semver";
import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { LRUCache } from 'lru-cache';
import {
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
S3Client,
} from "@aws-sdk/client-s3";
import { LRUCache } from "lru-cache";
import { streamToString, streamToBuffer, toSemverRange, verifyHash } from "./helpers";
import {
getDeviceRolloutBucket,
streamToString,
toSemverRange,
verifyHash,
} from "./helpers";
import { z, ZodError } from "zod";
const DEFAULT_SKU = "jetkvm-v2";
/** Query param schema builders for common patterns */
const queryString = () =>
z
.string()
.optional()
.transform(v => v || undefined);
const queryBoolean = () =>
z
.string()
.optional()
.transform(v => v === "true");
const querySku = () =>
z
.string()
.optional()
.transform(v => v || DEFAULT_SKU);
/**
* Schema for redirect endpoints (RetrieveLatestApp, RetrieveLatestSystemRecovery).
* Only needs prerelease flag and SKU (defaults to jetkvm-v2).
*/
const latestQuerySchema = z.object({
prerelease: queryBoolean(),
sku: querySku(),
});
type LatestQuery = z.infer<typeof latestQuerySchema>;
/**
* Schema for the main Retrieve endpoint.
* Requires deviceId and includes version constraints and forceUpdate flag.
*/
const retrieveQuerySchema = z.object({
deviceId: z.string({ error: "Device ID is required" }).min(1, "Device ID is required"),
prerelease: queryBoolean(),
appVersion: queryString(),
systemVersion: queryString(),
sku: querySku(),
forceUpdate: queryBoolean(),
});
type RetrieveQuery = z.infer<typeof retrieveQuerySchema>;
/**
* Parses query parameters and converts ZodError to BadRequestError.
*/
function parseQuery<T>(schema: z.ZodSchema<T>, req: Request): T {
try {
return schema.parse(req.query);
} catch (error) {
if (error instanceof ZodError) {
const message = error.issues.map((e: z.ZodIssue) => e.message).join(", ");
throw new BadRequestError(message);
}
throw error;
}
}
export interface ReleaseMetadata {
version: string;
@@ -45,16 +115,103 @@ export function clearCaches() {
const bucketName = process.env.R2_BUCKET;
const baseUrl = process.env.R2_CDN_URL;
/**
* Checks if an object exists in S3/R2 by attempting a HeadObjectCommand.
* Returns true if the object exists, false otherwise.
*/
async function s3ObjectExists(key: string): Promise<boolean> {
try {
await s3Client.send(new HeadObjectCommand({ Bucket: bucketName, Key: key }));
return true;
} catch (error: any) {
// HeadObjectCommand throws NotFound, but some S3-compatible stores (like R2) may throw NoSuchKey
if (
error.name === "NotFound" ||
error.name === "NoSuchKey" ||
error.$metadata?.httpStatusCode === 404
) {
return false;
}
throw error;
}
}
/**
* 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<boolean> {
const response = await s3Client.send(
new ListObjectsV2Command({
Bucket: bucketName,
Prefix: `${prefix}/${version}/skus/`,
MaxKeys: 1,
}),
);
return (response.Contents?.length ?? 0) > 0;
}
/**
* Resolves the artifact path for a given version and SKU.
*
* For versions with SKU support (skus/ folder exists):
* - Uses the provided SKU
* - 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
* that hardware and may not be compatible
*
* @param prefix - The prefix folder ("app" or "system")
* @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)
*/
async function resolveArtifactPath(
prefix: "app" | "system",
version: string,
sku: string,
artifactOverride?: string,
): Promise<string> {
const artifact = artifactOverride ?? (prefix === "app" ? "jetkvm_app" : "system.tar");
if (await versionHasSkuSupport(prefix, version)) {
const skuPath = `${prefix}/${version}/skus/${sku}/${artifact}`;
if (await s3ObjectExists(skuPath)) {
return skuPath;
}
throw new NotFoundError(`SKU "${sku}" is not available for version ${version}`);
}
// 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}`;
}
throw new NotFoundError(
`Version ${version} predates SKU support and cannot serve SKU "${sku}"`,
);
}
async function getLatestVersion(
prefix: "app" | "system",
includePrerelease: boolean,
maxSatisfying: string = "*",
sku: string,
): Promise<ReleaseMetadata> {
const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}`;
const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`;
const cached = releaseCache.get(cacheKey);
if (cached) {
return cached;
}
if (cached) return cached;
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
@@ -82,16 +239,18 @@ async function getLatestVersion(
includePrerelease,
}) as string;
if (!latestVersion) {
throw new NotFoundError(`No version found under prefix ${prefix} that satisfies ${maxSatisfying}`);
throw new NotFoundError(
`No version found under prefix ${prefix} that satisfies ${maxSatisfying}`,
);
}
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
const url = `${baseUrl}/${prefix}/${latestVersion}/${fileName}`;
const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku);
const url = `${baseUrl}/${selectedPath}`;
const hashResponse = await s3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: `${prefix}/${latestVersion}/${fileName}.sha256`,
Key: `${selectedPath}.sha256`,
}),
);
@@ -139,7 +298,10 @@ function setSystemRelease(release: Release, systemRelease: ReleaseMetadata) {
release.systemMaxSatisfying = systemRelease._maxSatisfying;
}
function toRelease(appRelease?: ReleaseMetadata, systemRelease?: ReleaseMetadata): Release {
function toRelease(
appRelease?: ReleaseMetadata,
systemRelease?: ReleaseMetadata,
): Release {
const release: Partial<Release> = {};
if (appRelease) setAppRelease(release as Release, appRelease);
if (systemRelease) setSystemRelease(release as Release, systemRelease);
@@ -148,11 +310,15 @@ function toRelease(appRelease?: ReleaseMetadata, systemRelease?: ReleaseMetadata
async function getReleaseFromS3(
includePrerelease: boolean,
{ appVersion, systemVersion }: { appVersion?: string; systemVersion?: string },
{
appVersion,
systemVersion,
sku,
}: { appVersion?: string; systemVersion?: string; sku: string },
): Promise<Release> {
const [appRelease, systemRelease] = await Promise.all([
getLatestVersion("app", includePrerelease, appVersion),
getLatestVersion("system", includePrerelease, systemVersion),
getLatestVersion("app", includePrerelease, appVersion, sku),
getLatestVersion("system", includePrerelease, systemVersion, sku),
]);
return toRelease(appRelease, systemRelease);
@@ -163,12 +329,7 @@ async function isDeviceEligibleForLatestRelease(
deviceId: string,
): Promise<boolean> {
if (rolloutPercentage === 100) return true;
const hash = createHash("md5").update(deviceId).digest("hex");
const hashPrefix = hash.substring(0, 8);
const hashValue = parseInt(hashPrefix, 16) % 100;
return hashValue < rolloutPercentage;
return getDeviceRolloutBucket(deviceId) < rolloutPercentage;
}
async function getDefaultRelease(type: "app" | "system") {
@@ -198,22 +359,20 @@ async function getDefaultRelease(type: "app" | "system") {
}
export async function Retrieve(req: Request, res: Response) {
// verify params
const deviceId = req.query.deviceId as string | undefined;
if (!deviceId) {
throw new BadRequestError("Device ID is required");
}
const query = parseQuery(retrieveQuerySchema, req);
const includePrerelease = req.query.prerelease === "true";
const appVersion = toSemverRange(req.query.appVersion as string | undefined);
const systemVersion = toSemverRange(req.query.systemVersion as string | undefined);
const appVersion = toSemverRange(query.appVersion);
const systemVersion = toSemverRange(query.systemVersion);
const skipRollout = appVersion !== "*" || systemVersion !== "*";
// Get the latest release from S3
let remoteRelease: Release;
try {
remoteRelease = await getReleaseFromS3(includePrerelease, { appVersion, systemVersion });
remoteRelease = await getReleaseFromS3(query.prerelease, {
appVersion,
systemVersion,
sku: query.sku,
});
} catch (error) {
console.error(error);
if (error instanceof NotFoundError) {
@@ -227,7 +386,7 @@ export async function Retrieve(req: Request, res: Response) {
// This also prevents us from storing the rollout percentage for prerelease versions
// If the version isn't a wildcard, we skip the rollout percentage check
if (includePrerelease || skipRollout) {
if (query.prerelease || skipRollout) {
return res.json(remoteRelease);
}
@@ -264,11 +423,8 @@ export async function Retrieve(req: Request, res: Response) {
This occurs when a user manually checks for updates in the app UI.
Background update checks follow the normal rollout percentage rules, to ensure controlled, gradual deployment of updates.
*/
const forceUpdate = req.query.forceUpdate === "true";
if (forceUpdate) {
return res.json(
toRelease(latestAppRelease, latestSystemRelease),
);
if (query.forceUpdate) {
return res.json(toRelease(latestAppRelease, latestSystemRelease));
}
const defaultAppRelease = await getDefaultRelease("app");
@@ -277,7 +433,10 @@ export async function Retrieve(req: Request, res: Response) {
const responseJson = toRelease(defaultAppRelease, defaultSystemRelease);
if (
await isDeviceEligibleForLatestRelease(latestAppRelease.rolloutPercentage, deviceId)
await isDeviceEligibleForLatestRelease(
latestAppRelease.rolloutPercentage,
query.deviceId,
)
) {
setAppRelease(responseJson, latestAppRelease);
}
@@ -285,7 +444,7 @@ export async function Retrieve(req: Request, res: Response) {
if (
await isDeviceEligibleForLatestRelease(
latestSystemRelease.rolloutPercentage,
deviceId,
query.deviceId,
)
) {
setSystemRelease(responseJson, latestSystemRelease);
@@ -294,23 +453,32 @@ export async function Retrieve(req: Request, res: Response) {
return res.json(responseJson);
}
function cachedRedirect(cachedKey: (req: Request) => string, callback: (req: Request) => Promise<string>) {
function cachedRedirect(
cachedKey: (query: LatestQuery) => string,
callback: (query: LatestQuery) => Promise<string>,
) {
return async (req: Request, res: Response) => {
const cacheKey = cachedKey(req);
const query = parseQuery(latestQuerySchema, req);
const cacheKey = cachedKey(query);
let result = redirectCache.get(cacheKey);
if (!result) {
result = await callback(req);
result = await callback(query);
redirectCache.set(cacheKey, result);
}
return res.redirect(302, result);
};
}
export const RetrieveLatestSystemRecovery = cachedRedirect(
(req: Request) => `system-recovery-${req.query.prerelease === "true" ? "pre" : "stable"}`,
async (req: Request) => {
const includePrerelease = req.query.prerelease === "true";
/**
* Generates a cache key for release endpoints based on prefix, prerelease flag, and SKU.
*/
function releaseCacheKey(prefix: string, query: LatestQuery): string {
return `${prefix}-${query.prerelease ? "pre" : "stable"}-${query.sku}`;
}
export const RetrieveLatestSystemRecovery = cachedRedirect(
query => releaseCacheKey("system-recovery", query),
async query => {
// Get the latest system recovery image from S3. It's stored in the system/ folder.
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
@@ -330,25 +498,33 @@ export const RetrieveLatestSystemRecovery = cachedRedirect(
.filter(v => semver.valid(v));
const latestVersion = semver.maxSatisfying(versions, "*", {
includePrerelease,
includePrerelease: query.prerelease,
}) as string;
if (!latestVersion) {
throw new NotFoundError("No valid system recovery versions found");
}
// Resolve the artifact path with SKU support (using update.img for recovery)
const artifactPath = await resolveArtifactPath(
"system",
latestVersion,
query.sku,
"update.img",
);
const [firmwareFile, hashFile] = await Promise.all([
// TODO: store file hash using custom header to avoid extra request
s3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: `system/${latestVersion}/update.img`,
Key: artifactPath,
}),
),
s3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: `system/${latestVersion}/update.img.sha256`,
Key: `${artifactPath}.sha256`,
}),
),
]);
@@ -363,15 +539,13 @@ export const RetrieveLatestSystemRecovery = cachedRedirect(
console.log("system recovery image hash matches", latestVersion);
return `${baseUrl}/system/${latestVersion}/update.img`;
return `${baseUrl}/${artifactPath}`;
},
);
export const RetrieveLatestApp = cachedRedirect(
(req: Request) => `app-${req.query.prerelease === "true" ? "pre" : "stable"}`,
async (req: Request) => {
const includePrerelease = req.query.prerelease === "true";
query => releaseCacheKey("app", query),
async query => {
// Get the latest version
const listCommand = new ListObjectsV2Command({
Bucket: bucketName,
@@ -384,30 +558,33 @@ export const RetrieveLatestApp = cachedRedirect(
throw new NotFoundError("No app versions found");
}
const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter(v =>
semver.valid(v),
const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter(
v => semver.valid(v),
);
const latestVersion = semver.maxSatisfying(versions, "*", {
includePrerelease,
includePrerelease: query.prerelease,
}) as string;
if (!latestVersion) {
throw new NotFoundError("No valid app versions found");
}
// 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: `app/${latestVersion}/jetkvm_app`,
Key: artifactPath,
}),
),
s3Client.send(
new GetObjectCommand({
Bucket: bucketName,
Key: `app/${latestVersion}/jetkvm_app.sha256`,
Key: `${artifactPath}.sha256`,
}),
),
]);
@@ -419,5 +596,6 @@ export const RetrieveLatestApp = cachedRedirect(
await verifyHash(appFile, hashFile, "app hash does not match");
console.log("App hash matches", latestVersion);
return `${baseUrl}/app/${latestVersion}/jetkvm_app`;
});
return `${baseUrl}/${artifactPath}`;
},
);
+610 -18
View File
@@ -1,12 +1,17 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Request, Response } from "express";
import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3Mock, createAsyncIterable, testPrisma, seedReleases, setRollout, resetToSeedData } from "./setup";
import { BadRequestError, NotFoundError, InternalServerError } from "../src/errors";
import { createHash } from "crypto";
// Import the module under test after setup
import { Retrieve, RetrieveLatestApp, RetrieveLatestSystemRecovery, clearCaches } from "../src/releases";
import {
Retrieve,
RetrieveLatestApp,
RetrieveLatestSystemRecovery,
clearCaches,
} from "../src/releases";
import { getDeviceRolloutBucket } from "../src/helpers";
// Helper to create mock Request
function createMockRequest(query: Record<string, string | undefined> = {}): Request {
@@ -41,22 +46,60 @@ function mockS3ListVersions(prefix: "app" | "system", versions: string[]) {
});
}
// Mock S3 hash file response
// Mock S3 hash file response for legacy versions (no SKU support)
function mockS3HashFile(prefix: "app" | "system", version: string, hash: string) {
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
Contents: [],
});
// Mock legacy hash path
s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}.sha256` }).resolves({
Body: createAsyncIterable(hash) as any,
});
}
// Mock S3 file and hash for redirect endpoints
function mockS3FileWithHash(
// Mock S3 for versions with SKU support
function mockS3SkuVersion(
prefix: "app" | "system",
version: string,
sku: string,
hash: string,
) {
const fileName = prefix === "app" ? "jetkvm_app" : "system.tar";
const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`;
// Mock versionHasSkuSupport to return true (has SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
Contents: [{ Key: skuPath }],
});
// Mock SKU artifact exists (HeadObjectCommand for existence check)
s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({});
// Mock SKU hash path
s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({
Body: createAsyncIterable(hash) as any,
});
}
// Mock S3 for legacy version with file content (for redirect endpoints with hash verification)
function mockS3LegacyVersionWithContent(
prefix: "app" | "system",
version: string,
fileName: string,
content: string,
hash: string
) {
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
Contents: [],
});
// Mock legacy file path with content
s3Mock.on(GetObjectCommand, { Key: `${prefix}/${version}/${fileName}` }).resolves({
Body: createAsyncIterable(content) as any,
});
@@ -65,16 +108,40 @@ function mockS3FileWithHash(
});
}
function rolloutBucket(deviceId: string) {
const hash = createHash("md5").update(deviceId).digest("hex");
const hashPrefix = hash.substring(0, 8);
return parseInt(hashPrefix, 16) % 100;
// Mock S3 for SKU version with file content (for redirect endpoints with hash verification)
function mockS3SkuVersionWithContent(
prefix: "app" | "system",
version: string,
sku: string,
fileName: string,
content: string,
hash: string
) {
const skuPath = `${prefix}/${version}/skus/${sku}/${fileName}`;
// Mock versionHasSkuSupport to return true (has SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: `${prefix}/${version}/skus/` }).resolves({
Contents: [{ Key: skuPath }],
});
// Mock SKU artifact exists (HeadObjectCommand for existence check)
s3Mock.on(HeadObjectCommand, { Key: skuPath }).resolves({});
// Mock SKU artifact with content (GetObjectCommand for actual fetch)
s3Mock.on(GetObjectCommand, { Key: skuPath }).resolves({
Body: createAsyncIterable(content) as any,
});
// Mock SKU hash path
s3Mock.on(GetObjectCommand, { Key: `${skuPath}.sha256` }).resolves({
Body: createAsyncIterable(hash) as any,
});
}
function findDeviceIdOutsideRollout(threshold: number) {
for (let i = 0; i < 10000; i += 1) {
const candidate = `device-not-eligible-${i}`;
if (rolloutBucket(candidate) >= threshold) {
if (getDeviceRolloutBucket(candidate) >= threshold) {
return candidate;
}
}
@@ -84,7 +151,7 @@ function findDeviceIdOutsideRollout(threshold: number) {
function findDeviceIdInsideRollout(threshold: number) {
for (let i = 0; i < 10000; i += 1) {
const candidate = `device-eligible-${i}`;
if (rolloutBucket(candidate) < threshold) {
if (getDeviceRolloutBucket(candidate) < threshold) {
return candidate;
}
}
@@ -227,6 +294,8 @@ describe("Retrieve handler", () => {
mockS3ListVersions("system", ["1.0.0", "2.0.0"]);
mockS3HashFile("app", "1.0.0", "app-hash-100");
mockS3HashFile("system", "1.0.0", "system-hash-100");
await setRollout("1.0.0", "app", 0);
await setRollout("1.0.0", "system", 0);
await Retrieve(req, res);
@@ -245,6 +314,143 @@ describe("Retrieve handler", () => {
});
});
describe("SKU handling", () => {
it("should use legacy path when no SKU provided on legacy version", async () => {
// Pin versions to bypass rollout; SKU behavior is the only variable here.
const req = createMockRequest({
deviceId: "device-123",
appVersion: "1.0.0",
systemVersion: "1.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["1.0.0"]);
mockS3ListVersions("system", ["1.0.0"]);
mockS3HashFile("app", "1.0.0", "legacy-app-hash");
mockS3HashFile("system", "1.0.0", "legacy-system-hash");
await Retrieve(req, res);
expect(res._json.appVersion).toBe("1.0.0");
expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app");
expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar");
});
it("should use legacy path when default SKU provided on legacy version", async () => {
// Pin versions to bypass rollout; SKU behavior is the only variable here.
const req = createMockRequest({
deviceId: "device-123",
sku: "jetkvm-v2",
appVersion: "1.0.0",
systemVersion: "1.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["1.0.0"]);
mockS3ListVersions("system", ["1.0.0"]);
mockS3HashFile("app", "1.0.0", "legacy-app-hash-2");
mockS3HashFile("system", "1.0.0", "legacy-system-hash-2");
await Retrieve(req, res);
expect(res._json.appVersion).toBe("1.0.0");
expect(res._json.appUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app");
expect(res._json.systemUrl).toBe("https://cdn.test.com/system/1.0.0/system.tar");
});
it("should throw NotFoundError when non-default SKU requested on legacy version", async () => {
// Pin versions to bypass rollout; SKU behavior is the only variable here.
const req = createMockRequest({
deviceId: "device-123",
sku: "jetkvm-2",
appVersion: "1.0.0",
systemVersion: "1.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["1.0.0"]);
mockS3ListVersions("system", ["1.0.0"]);
mockS3HashFile("app", "1.0.0", "legacy-app-hash-3");
mockS3HashFile("system", "1.0.0", "legacy-system-hash-3");
await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError);
await expect(Retrieve(req, res)).rejects.toThrow("predates SKU support");
});
it("should use SKU path when version has SKU support", async () => {
const req = createMockRequest({
deviceId: "device-123",
sku: "jetkvm-2",
appVersion: "^2.0.0",
systemVersion: "^2.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["2.0.0"]);
mockS3ListVersions("system", ["2.0.0"]);
mockS3SkuVersion("app", "2.0.0", "jetkvm-2", "sku-app-hash");
mockS3SkuVersion("system", "2.0.0", "jetkvm-2", "sku-system-hash");
await Retrieve(req, res);
expect(res._json.appVersion).toBe("2.0.0");
expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app");
expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/system.tar");
});
it("should use default SKU when no SKU provided on version with SKU support", async () => {
const req = createMockRequest({
deviceId: "device-123",
appVersion: "^2.0.0",
systemVersion: "^2.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["2.0.0"]);
mockS3ListVersions("system", ["2.0.0"]);
mockS3SkuVersion("app", "2.0.0", "jetkvm-v2", "default-sku-app-hash");
mockS3SkuVersion("system", "2.0.0", "jetkvm-v2", "default-sku-system-hash");
await Retrieve(req, res);
expect(res._json.appVersion).toBe("2.0.0");
expect(res._json.appUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app");
expect(res._json.systemUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/system.tar");
});
it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => {
const req = createMockRequest({
deviceId: "device-123",
sku: "jetkvm-3",
appVersion: "^2.0.0",
systemVersion: "^2.0.0",
});
const res = createMockResponse();
mockS3ListVersions("app", ["2.0.0"]);
mockS3ListVersions("system", ["2.0.0"]);
// Version has SKU support (jetkvm-v2 exists) but jetkvm-3 doesn't
s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({
Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }],
});
s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({
Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/system.tar" }],
});
s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/system.tar" }).rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
await expect(Retrieve(req, res)).rejects.toThrow(NotFoundError);
await expect(Retrieve(req, res)).rejects.toThrow("is not available for version");
});
});
describe("forceUpdate mode", () => {
it("should return latest release when forceUpdate=true", async () => {
// Use unique version constraints to get unique cache keys
@@ -645,7 +851,7 @@ describe("RetrieveLatestApp handler", () => {
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3FileWithHash("app", "1.2.0", "jetkvm_app", content, hash);
mockS3LegacyVersionWithContent("app", "1.2.0", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
@@ -668,7 +874,7 @@ describe("RetrieveLatestApp handler", () => {
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3FileWithHash("app", "2.0.0-beta.1", "jetkvm_app", content, hash);
mockS3LegacyVersionWithContent("app", "2.0.0-beta.1", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
@@ -683,7 +889,7 @@ describe("RetrieveLatestApp handler", () => {
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
mockS3FileWithHash("app", "1.0.0", "jetkvm_app", "actual-content", "wrong-hash-value");
mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", "actual-content", "wrong-hash-value");
await expect(RetrieveLatestApp(req, res)).rejects.toThrow(InternalServerError);
});
@@ -696,6 +902,11 @@ describe("RetrieveLatestApp handler", () => {
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: "app/1.0.0/skus/" }).resolves({
Contents: [],
});
s3Mock.on(GetObjectCommand, { Key: "app/1.0.0/jetkvm_app" }).resolves({
Body: undefined,
});
@@ -705,6 +916,194 @@ describe("RetrieveLatestApp handler", () => {
await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError);
});
describe("SKU handling", () => {
it("should use legacy path when no SKU provided on legacy version", async () => {
const req = createMockRequest({});
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
const content = "legacy-app-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app");
});
it("should use legacy path when default SKU provided on legacy version", async () => {
const req = createMockRequest({ sku: "jetkvm-v2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
const content = "legacy-app-content-default-sku";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/app/1.0.0/jetkvm_app");
});
it("should throw NotFoundError when non-default SKU requested on legacy version", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: "app/1.0.0/skus/" }).resolves({
Contents: [],
});
await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError);
await expect(RetrieveLatestApp(req, res)).rejects.toThrow("predates SKU support");
});
it("should use SKU path when version has SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/2.0.0/" }],
});
const content = "sku-app-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
expect(res.redirect).toHaveBeenCalledWith(
302,
"https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app"
);
});
it("should use default SKU when no SKU provided on version with SKU support", async () => {
const req = createMockRequest({});
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/2.0.0/" }],
});
const content = "default-sku-app-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-v2", "jetkvm_app", content, hash);
await RetrieveLatestApp(req, res);
expect(res.redirect).toHaveBeenCalledWith(
302,
"https://cdn.test.com/app/2.0.0/skus/jetkvm-v2/jetkvm_app"
);
});
it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-3" });
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
s3Mock.on(ListObjectsV2Command, { Prefix: "app/2.0.0/skus/" }).resolves({
Contents: [{ Key: "app/2.0.0/skus/jetkvm-v2/jetkvm_app" }],
});
s3Mock.on(HeadObjectCommand, { Key: "app/2.0.0/skus/jetkvm-3/jetkvm_app" }).rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
await expect(RetrieveLatestApp(req, res)).rejects.toThrow(NotFoundError);
await expect(RetrieveLatestApp(req, res)).rejects.toThrow("is not available for version");
});
});
describe("cache behavior", () => {
it("should return cached redirect on second call with same parameters", async () => {
const req1 = createMockRequest({});
const res1 = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
const content = "cached-app-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash);
await RetrieveLatestApp(req1, res1);
expect(res1._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app");
// Reset S3 mock to return different data
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/2.0.0/" }],
});
mockS3LegacyVersionWithContent("app", "2.0.0", "jetkvm_app", "new-content", "new-hash");
// Second call should return cached result (1.0.0), not new S3 data (2.0.0)
const req2 = createMockRequest({});
const res2 = createMockResponse();
await RetrieveLatestApp(req2, res2);
expect(res2._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app");
});
it("should use different cache keys for different SKUs", async () => {
// First call with default SKU
const req1 = createMockRequest({});
const res1 = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/1.0.0/" }],
});
const content = "sku-cache-test";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("app", "1.0.0", "jetkvm_app", content, hash);
await RetrieveLatestApp(req1, res1);
expect(res1._redirectUrl).toBe("https://cdn.test.com/app/1.0.0/jetkvm_app");
// Second call with different SKU should NOT use cached result
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "app/" }).resolves({
CommonPrefixes: [{ Prefix: "app/2.0.0/" }],
});
mockS3SkuVersionWithContent("app", "2.0.0", "jetkvm-2", "jetkvm_app", content, hash);
const req2 = createMockRequest({ sku: "jetkvm-2" });
const res2 = createMockResponse();
await RetrieveLatestApp(req2, res2);
expect(res2._redirectUrl).toBe("https://cdn.test.com/app/2.0.0/skus/jetkvm-2/jetkvm_app");
});
});
});
describe("RetrieveLatestSystemRecovery handler", () => {
@@ -754,7 +1153,7 @@ describe("RetrieveLatestSystemRecovery handler", () => {
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3FileWithHash("system", "1.2.0", "update.img", content, hash);
mockS3LegacyVersionWithContent("system", "1.2.0", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req, res);
@@ -776,7 +1175,7 @@ describe("RetrieveLatestSystemRecovery handler", () => {
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3FileWithHash("system", "2.0.0-alpha.1", "update.img", content, hash);
mockS3LegacyVersionWithContent("system", "2.0.0-alpha.1", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req, res);
@@ -794,7 +1193,7 @@ describe("RetrieveLatestSystemRecovery handler", () => {
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
mockS3FileWithHash("system", "1.0.0", "update.img", "actual-content", "mismatched-hash");
mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", "actual-content", "mismatched-hash");
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(InternalServerError);
});
@@ -807,6 +1206,11 @@ describe("RetrieveLatestSystemRecovery handler", () => {
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: "system/1.0.0/skus/" }).resolves({
Contents: [],
});
s3Mock.on(GetObjectCommand, { Key: "system/1.0.0/update.img" }).resolves({
Body: undefined,
});
@@ -816,4 +1220,192 @@ describe("RetrieveLatestSystemRecovery handler", () => {
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError);
});
describe("SKU handling", () => {
it("should use legacy path when no SKU provided on legacy version", async () => {
const req = createMockRequest({});
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
const content = "legacy-recovery-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req, res);
expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img");
});
it("should use legacy path when default SKU provided on legacy version", async () => {
const req = createMockRequest({ sku: "jetkvm-v2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
const content = "legacy-recovery-content-default-sku";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req, res);
expect(res.redirect).toHaveBeenCalledWith(302, "https://cdn.test.com/system/1.0.0/update.img");
});
it("should throw NotFoundError when non-default SKU requested on legacy version", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
// Mock versionHasSkuSupport to return false (no SKU folders)
s3Mock.on(ListObjectsV2Command, { Prefix: "system/1.0.0/skus/" }).resolves({
Contents: [],
});
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError);
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("predates SKU support");
});
it("should use SKU path when version has SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-2" });
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
const content = "sku-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", content, hash);
await RetrieveLatestSystemRecovery(req, res);
expect(res.redirect).toHaveBeenCalledWith(
302,
"https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img"
);
});
it("should use default SKU when no SKU provided on version with SKU support", async () => {
const req = createMockRequest({});
const res = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
const content = "default-sku-recovery-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-v2", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req, res);
expect(res.redirect).toHaveBeenCalledWith(
302,
"https://cdn.test.com/system/2.0.0/skus/jetkvm-v2/update.img"
);
});
it("should throw NotFoundError when requested SKU not available on version with SKU support", async () => {
const req = createMockRequest({ sku: "jetkvm-3" });
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
s3Mock.on(ListObjectsV2Command, { Prefix: "system/2.0.0/skus/" }).resolves({
Contents: [{ Key: "system/2.0.0/skus/jetkvm-v2/update.img" }],
});
s3Mock.on(HeadObjectCommand, { Key: "system/2.0.0/skus/jetkvm-3/update.img" }).rejects({
name: "NoSuchKey",
$metadata: { httpStatusCode: 404 },
});
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow(NotFoundError);
await expect(RetrieveLatestSystemRecovery(req, res)).rejects.toThrow("is not available for version");
});
});
describe("cache behavior", () => {
it("should return cached redirect on second call with same parameters", async () => {
const req1 = createMockRequest({});
const res1 = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
const content = "cached-system-recovery-content";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash);
await RetrieveLatestSystemRecovery(req1, res1);
expect(res1._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img");
// Reset S3 mock to return different data
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
mockS3LegacyVersionWithContent("system", "2.0.0", "update.img", "new-content", "new-hash");
// Second call should return cached result (1.0.0), not new S3 data (2.0.0)
const req2 = createMockRequest({});
const res2 = createMockResponse();
await RetrieveLatestSystemRecovery(req2, res2);
expect(res2._redirectUrl).toBe("https://cdn.test.com/system/1.0.0/update.img");
});
it("should use different cache keys for different SKUs", async () => {
// First call with default SKU
const req1 = createMockRequest({});
const res1 = createMockResponse();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/1.0.0/" }],
});
const content = "sku-cache-test-recovery";
const crypto = await import("crypto");
const hash = crypto.createHash("sha256").update(content).digest("hex");
mockS3LegacyVersionWithContent("system", "1.0.0", "update.img", content, hash);
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
s3Mock.reset();
s3Mock.on(ListObjectsV2Command, { Prefix: "system/" }).resolves({
CommonPrefixes: [{ Prefix: "system/2.0.0/" }],
});
mockS3SkuVersionWithContent("system", "2.0.0", "jetkvm-2", "update.img", content, hash);
const req2 = createMockRequest({ sku: "jetkvm-2" });
const res2 = createMockResponse();
await RetrieveLatestSystemRecovery(req2, res2);
expect(res2._redirectUrl).toBe("https://cdn.test.com/system/2.0.0/skus/jetkvm-2/update.img");
});
});
});