mirror of
https://github.com/jetkvm/cloud-api.git
synced 2026-05-21 05:20:36 +00:00
OTA for specific SKUs (#49)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ npx prisma migrate deploy
|
||||
|
||||
# Start the production server on port 3000
|
||||
npm run dev
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Generated
+12
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user