Files
Adam Shiervani b24a057591 Add SKU-aware OTA release artifacts (#56)
* feat: add SKU-aware OTA release artifacts

Persist OTA artifact URL/hash data separately from rollout state so stable release responses can choose artifacts by compatible SKU while release rollout remains version/type based.

* fix: select compatible OTA releases by SKU

Ensure stable release selection only considers releases with artifacts compatible with the requested SKU, and tighten tests around the DB-backed OTA contract.

* fix: match production OTA release responses

Only expose stable signature URLs that actually exist and preserve production's version-first SKU error behavior.

* fix: restrict legacy OTA artifacts and make sync create-only

Pre-SKU artifacts (no skus/ folder) are jetkvm-v2 only. Marking them
compatible with jetkvm-v2-sdmmc would brick devices that received
firmware predating their hardware. Future SKUs must opt in via an
explicit skus/<sku>/ upload.

sync-releases now skips releases already in the DB instead of upserting
them. This prevents routine sync runs from rewriting Release.url/hash
or appending duplicate ReleaseArtifact rows if R2_CDN_URL ever changes.
Backfills and repairs are left to one-off scripts.

* refactor: drop forceUpdate query parameter from /releases

The flag is no longer sent by any client. Routine update checks now
always go through the rollout-aware default-and-latest path, which is
what forceUpdate effectively short-circuited to. Removes one query
parameter, one branch in the handler, and the corresponding axis from
the compare-releases sweep.

* fix: skip incompatible defaults and parallelize stable DB lookups

getDefaultRelease previously picked the newest 100%-rolled-out release
without checking SKU compatibility. If that release lacked a compatible
artifact, the request 404'd downstream even though older 100%-rolled-out
releases had valid binaries for the SKU. It now filters to releases that
actually ship a compatible artifact before selecting the latest, falling
back to a 404 only when no compatible default exists.

The four DB lookups in the stable rollout-aware path are independent; run
them concurrently so background-check latency drops from ~4 round trips
to ~1.
2026-04-27 19:18:54 +02:00

239 lines
6.8 KiB
TypeScript

import { beforeAll, afterAll, afterEach } from "vitest";
import { mockClient } from "aws-sdk-client-mock";
import { S3Client } from "@aws-sdk/client-s3";
import { PrismaClient } from "@prisma/client";
import { config } from "dotenv";
// Load .env.development for config
config({ path: ".env.development" });
// Use compose.yaml database credentials (jetkvm user, not postgres)
process.env.DATABASE_URL = "postgresql://jetkvm:jetkvm@localhost:5432/jetkvm?schema=public";
// Override S3 config for testing (mock responses)
process.env.NODE_ENV = "test";
process.env.R2_ENDPOINT = "https://test.r2.cloudflarestorage.com";
process.env.R2_ACCESS_KEY_ID = "test-access-key";
process.env.R2_SECRET_ACCESS_KEY = "test-secret-key";
process.env.R2_BUCKET = "test-bucket";
process.env.R2_CDN_URL = "https://cdn.test.com";
// Create S3 mock that can be used across tests
export const s3Mock = mockClient(S3Client);
// Create a test Prisma client
export const testPrisma = new PrismaClient();
type ReleaseType = "app" | "system";
// Pre-SKU artifacts are jetkvm-v2 only; future SKUs need explicit
// skus/<sku>/ uploads, registered via scripts/sync-releases.ts.
const LEGACY_COMPATIBLE_SKUS = ["jetkvm-v2"];
function ensureSafeTestDatabase() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required for tests");
}
const parsed = new URL(databaseUrl);
const host = parsed.hostname;
const dbName = parsed.pathname.replace(/^\//, "");
const isLocalHost = host === "localhost" || host === "127.0.0.1";
const isTestDb = dbName === "jetkvm" || dbName.includes("test");
if (!isLocalHost || !isTestDb) {
throw new Error(
`Unsafe DATABASE_URL for tests: ${databaseUrl}. Refusing to run destructive test setup.`,
);
}
}
// Seed data for releases
interface SeedRelease {
version: string;
type: ReleaseType;
rolloutPercentage: number;
url: string;
hash: string;
}
export const seedReleases: SeedRelease[] = [
// App releases
{
version: "1.0.0",
type: "app",
rolloutPercentage: 100,
url: "https://cdn.test.com/app/1.0.0/jetkvm_app",
hash: "abc123hash100",
},
{
version: "1.1.0",
type: "app",
rolloutPercentage: 100,
url: "https://cdn.test.com/app/1.1.0/jetkvm_app",
hash: "abc123hash110",
},
{
version: "1.2.0",
type: "app",
rolloutPercentage: 10,
url: "https://cdn.test.com/app/1.2.0/jetkvm_app",
hash: "abc123hash120",
},
// System releases
{
version: "1.0.0",
type: "system",
rolloutPercentage: 100,
url: "https://cdn.test.com/system/1.0.0/system.tar",
hash: "sys123hash100",
},
{
version: "1.1.0",
type: "system",
rolloutPercentage: 100,
url: "https://cdn.test.com/system/1.1.0/system.tar",
hash: "sys123hash110",
},
{
version: "1.2.0",
type: "system",
rolloutPercentage: 10,
url: "https://cdn.test.com/system/1.2.0/system.tar",
hash: "sys123hash120",
},
];
function compatibleSkusForSeedRelease(_type: ReleaseType): string[] {
return LEGACY_COMPATIBLE_SKUS;
}
type SeedReleaseArtifactSource = Pick<SeedRelease, "type" | "url" | "hash">;
function seedReleaseArtifactData(releaseId: bigint, release: SeedReleaseArtifactSource) {
return {
releaseId,
url: release.url,
hash: release.hash,
compatibleSkus: compatibleSkusForSeedRelease(release.type),
};
}
async function createSeedRelease(release: SeedRelease): Promise<void> {
const createdRelease = await testPrisma.release.create({ data: release });
await testPrisma.releaseArtifact.create({
data: seedReleaseArtifactData(createdRelease.id, release),
});
}
// Helper to set rollout percentage for a specific version
export async function setRollout(
version: string,
type: ReleaseType,
percentage: number,
): Promise<void> {
const release = await testPrisma.release.upsert({
where: { version_type: { version, type } },
update: { rolloutPercentage: percentage },
create: {
version,
type,
rolloutPercentage: percentage,
url: `https://cdn.test.com/${type}/${version}/${type === "app" ? "jetkvm_app" : "system.tar"}`,
hash: `test-hash-${version}-${type}`,
},
});
const artifactData = seedReleaseArtifactData(release.id, release);
await testPrisma.releaseArtifact.upsert({
where: { releaseId_url: { releaseId: release.id, url: release.url } },
update: {
hash: artifactData.hash,
compatibleSkus: artifactData.compatibleSkus,
},
create: artifactData,
});
}
// Helper to reset all releases to seed data baseline
export async function resetToSeedData() {
// Delete any releases not in seed data
const seedVersionTypes = seedReleases.map(r => ({ version: r.version, type: r.type }));
await testPrisma.release.deleteMany({
where: {
NOT: {
OR: seedVersionTypes.map(vt => ({
version: vt.version,
type: vt.type,
})),
},
},
});
// Reset seed releases to original values
for (const release of seedReleases) {
const dbRelease = await testPrisma.release.upsert({
where: { version_type: { version: release.version, type: release.type } },
update: { rolloutPercentage: release.rolloutPercentage, url: release.url, hash: release.hash },
create: release,
});
await testPrisma.releaseArtifact.deleteMany({ where: { releaseId: dbRelease.id } });
await testPrisma.releaseArtifact.create({
data: seedReleaseArtifactData(dbRelease.id, release),
});
}
}
// Helper to create a readable stream from a string (for S3 mock responses)
export function createMockStream(content: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content));
controller.close();
},
});
}
// Helper to create async iterable from string (for streamToString/streamToBuffer)
export function createAsyncIterable(content: string | Buffer) {
const data = typeof content === "string" ? Buffer.from(content) : content;
return {
async *[Symbol.asyncIterator]() {
yield data;
},
};
}
beforeAll(async () => {
ensureSafeTestDatabase();
// Connect to the test database
await testPrisma.$connect();
// Clean up existing releases
await testPrisma.releaseArtifact.deleteMany({});
await testPrisma.release.deleteMany({});
// Seed the database with test releases
for (const release of seedReleases) {
await createSeedRelease(release);
}
});
afterEach(() => {
// Reset S3 mock after each test
s3Mock.reset();
// Reset DB to seed state after each test to avoid cross-test coupling
return resetToSeedData();
});
afterAll(async () => {
// Clean up after all tests
await testPrisma.releaseArtifact.deleteMany({});
await testPrisma.release.deleteMany({});
await testPrisma.$disconnect();
});