diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5a0ae31..12fe432 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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 diff --git a/README.md b/README.md index e4379b6..d09cf19 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ npx prisma migrate deploy # Start the production server on port 3000 npm run dev + +# Run tests +npm test ``` ## Production diff --git a/package-lock.json b/package-lock.json index bbd369d..8579101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 1c25eaa..a0bc976 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/devices.ts b/src/devices.ts index 25d7276..ba42ccf 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -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]; diff --git a/src/helpers.ts b/src/helpers.ts index 4212bff..453e040 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -32,7 +32,9 @@ export async function verifyHash( ): Promise { 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; } \ No newline at end of file diff --git a/src/releases.ts b/src/releases.ts index 14fc2dd..39d590c 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -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 { + 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; + +/** + * 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; -import { streamToString, streamToBuffer, toSemverRange, verifyHash } from "./helpers"; +/** + * Parses query parameters and converts ZodError to BadRequestError. + */ +function parseQuery(schema: z.ZodSchema, 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 { + 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 { + 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 { + 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 { - 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 = {}; 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 { 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 { 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 includePrerelease = req.query.prerelease === "true"; + const query = parseQuery(retrieveQuerySchema, req); - 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) { +function cachedRedirect( + cachedKey: (query: LatestQuery) => string, + callback: (query: LatestQuery) => Promise, +) { 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}`; + }, +); diff --git a/test/releases.test.ts b/test/releases.test.ts index 76e9ac9..9adf0cd 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -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 = {}): 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"); + }); + }); });