diff --git a/.dockerignore b/.dockerignore index 33d703b..9c432ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,5 @@ dist/ .DS_Store .env .git +.env.* +!.env.example diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 12fe432..cd37dbe 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -28,10 +28,14 @@ jobs: steps: - uses: actions/checkout@v4 + # Pulls and starts in the background while dependencies install; the end-to-end tests use it + - name: Start S3-compatible store + run: nohup docker compose -f compose.development.yaml up -d s3 > "$RUNNER_TEMP/s3-start.log" 2>&1 & + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: v22.21.0 + node-version: v22.23.4 cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -57,6 +61,11 @@ jobs: - name: Run Tests run: npm test + - name: Run end-to-end tests + run: | + timeout 180 sh -c 'until curl -s -o /dev/null http://127.0.0.1:8333; do sleep 1; done' || { cat "$RUNNER_TEMP/s3-start.log"; exit 1; } + npx vitest run --config vitest.e2e.config.ts + - name: Cache Prisma Binary uses: actions/cache@v4 with: diff --git a/Dockerfile b/Dockerfile index ad38c46..33ff589 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,34 @@ -FROM node:22.23.4-bookworm-slim +FROM node:22.23.4-bookworm-slim AS base LABEL org.opencontainers.image.source="https://github.com/jetkvm/cloud-api" WORKDIR /usr/src/app -# Prisma needs OpenSSL at runtime +# Prisma needs OpenSSL at runtime; tini forwards SIGTERM to node as PID 1 RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini \ && rm -rf /var/lib/apt/lists/* +FROM base AS build COPY package.json package-lock.json ./ COPY prisma ./prisma -# ts-node type-checks at startup, so the @types/* devDependencies are needed at runtime RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +# Type errors fail the build here instead of crashing the container at startup +RUN npx prisma generate \ + && npm run build \ + && npm prune --omit=dev -COPY . . -RUN npx prisma generate - +FROM base ENV NODE_ENV=production ENV PORT=3000 +COPY package.json ./ +COPY prisma ./prisma +COPY --from=build /usr/src/app/node_modules ./node_modules +COPY --from=build /usr/src/app/dist ./dist + +USER node EXPOSE 3000 -CMD ["npm", "run", "start"] +HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://localhost:' + process.env.PORT + '/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 0c431eb..d8b7acd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ If you've found an issue and want to report it, please check our [Issues](https: This project is built with Node.js, Prisma, and Express. ```bash -# Start the database +# Start the database and the S3-compatible store the e2e tests use docker compose -f compose.development.yaml up -d # Copy and configure environment variables @@ -50,6 +50,29 @@ npm run dev npm test ``` +### End-to-end tests + +`npm run test:e2e` builds the API and starts it as the container does, against a +database (`jetkvm_e2e`) and a bucket that only the e2e run uses. It needs the +services from `compose.development.yaml`. Override them with `E2E_DATABASE_URL` +and `E2E_S3_ENDPOINT`. + +### Comparing two deployments + +`npm run compare` sends the same requests to two deployments of the API and +reports where the answers differ, grouped by cause. In a terminal it asks you to +mark each group as expected or not, and exits with 0 only when you mark them all: + +```bash +npm run compare -- --candidate local +npm run compare -- --candidate staging --only releases,redirects +npm run compare -- --help +``` + +The reference defaults to `https://api.jetkvm.com`. Update checks that depend +on the rollout table differ unless both databases have the same rollout +percentages. + ## Self-Hosting For self-hosting, use the default compose file which runs the complete stack: diff --git a/compose.development.yaml b/compose.development.yaml index 5c601a4..ee0229f 100644 --- a/compose.development.yaml +++ b/compose.development.yaml @@ -19,6 +19,14 @@ services: volumes: - postgresql:/var/lib/postgresql + # S3-compatible store for the e2e tests (npm run test:e2e) + s3: + image: chrislusf/seaweedfs:4.50 + restart: unless-stopped + command: server -s3 -dir=/data + ports: + - "8333:8333" + volumes: postgresql: driver: local diff --git a/e2e/devices.test.ts b/e2e/devices.test.ts new file mode 100644 index 0000000..0bb2868 --- /dev/null +++ b/e2e/devices.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + api, + closed, + createUserWithDevice, + db, + openSocket, + sessionCookie, + unsignedIdToken, +} from "./helpers"; + +beforeEach(async () => { + await db.device.deleteMany({ where: { id: { startsWith: "e2e-dev-" } } }); + await db.user.deleteMany({ where: { googleId: { startsWith: "e2e-dev-" } } }); +}); + +function exchange(tempToken?: string) { + return api("/devices/token", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(tempToken === undefined ? {} : { tempToken }), + }); +} + +async function deviceWithTempToken(id: string, tempToken: string, expiresInMs: number) { + await db.user.create({ + data: { + googleId: `e2e-dev-${id}-owner`, + device: { + create: { id, tempToken, tempTokenExpiresAt: new Date(Date.now() + expiresInMs) }, + }, + }, + }); +} + +describe("device registration (POST /devices/token)", () => { + it("trades a temporary token for a secret token the device can connect with, once", async () => { + await deviceWithTempToken("e2e-dev-1", "temp-1", 60_000); + + const response = await exchange("temp-1"); + expect(response.status).toBe(200); + const { secretToken } = await response.json(); + expect(secretToken).toMatch(/^[0-9a-f]{40}$/); + + const device = await db.device.findUniqueOrThrow({ where: { id: "e2e-dev-1" } }); + expect(device).toMatchObject({ + secretToken, + tempToken: null, + tempTokenExpiresAt: null, + }); + + // The temporary token is single-use + expect((await exchange("temp-1")).status).toBe(404); + + const ws = await openSocket("/", { + authorization: `Bearer ${secretToken}`, + "x-device-id": "e2e-dev-1", + }); + ws.close(); + await closed(ws); + }); + + it("refuses an expired temporary token and leaves the device without a secret", async () => { + await deviceWithTempToken("e2e-dev-2", "temp-2", -1_000); + expect((await exchange("temp-2")).status).toBe(401); + expect( + (await db.device.findUniqueOrThrow({ where: { id: "e2e-dev-2" } })).secretToken, + ).toBeNull(); + }); + + it("answers 404 to an unknown token and 422 to a missing one", async () => { + expect((await exchange("never-issued")).status).toBe(404); + expect((await exchange()).status).toBe(422); + }); +}); + +describe("device removal (DELETE /devices/:id)", () => { + it("lets the device remove itself with its secret token", async () => { + await createUserWithDevice("e2e-dev-owner-3", "e2e-dev-3", "secret-3"); + + const response = await api("/devices/e2e-dev-3", { + method: "DELETE", + headers: { authorization: "Bearer secret-3" }, + }); + expect(response.status).toBe(204); + expect(await db.device.findUnique({ where: { id: "e2e-dev-3" } })).toBeNull(); + }); + + it("answers 404 to a secret token that belongs to no device", async () => { + await createUserWithDevice("e2e-dev-owner-4", "e2e-dev-4", "secret-4"); + const response = await api("/devices/e2e-dev-4", { + method: "DELETE", + headers: { authorization: "Bearer someone-elses-secret" }, + }); + expect(response.status).toBe(404); + expect(await db.device.findUnique({ where: { id: "e2e-dev-4" } })).not.toBeNull(); + }); + + it("answers 401 without credentials and keeps serving", async () => { + await createUserWithDevice("e2e-dev-owner-5", "e2e-dev-5", "secret-5"); + + const response = await api("/devices/e2e-dev-5", { + method: "DELETE", + signal: AbortSignal.timeout(5000), + }); + expect(response.status).toBe(401); + expect(await db.device.findUnique({ where: { id: "e2e-dev-5" } })).not.toBeNull(); + expect((await api("/healthz")).status).toBe(200); + }); + + it("answers 401 to a session whose ID token Google did not sign", async () => { + await createUserWithDevice("e2e-dev-owner-6", "e2e-dev-6", "secret-6"); + const cookie = sessionCookie({ + id_token: unsignedIdToken({ sub: "e2e-dev-owner-6" }), + }); + + const response = await api("/devices/e2e-dev-6", { + method: "DELETE", + headers: { cookie }, + signal: AbortSignal.timeout(5000), + }); + expect(response.status).toBe(401); + expect(await db.device.findUnique({ where: { id: "e2e-dev-6" } })).not.toBeNull(); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..a63db44 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,75 @@ +/** + * The release bucket the e2e API reads. Global setup uploads every artifact + * with a `.sha256` sidecar (and a `.sig` where `sig` is set), as the release + * scripts do, then registers each stable version through POST /releases/sync. + */ + +export const CDN_BASE = "https://cdn.e2e.test"; + +interface FixtureArtifact { + key: string; + sig?: boolean; +} + +export const ARTIFACTS: FixtureArtifact[] = [ + // App. 0.3.0 predates the skus/ layout, 0.5.0 has no SDMMC build. + { key: "app/0.3.0/jetkvm_app" }, + { key: "app/0.4.0/skus/jetkvm-v2/jetkvm_app", sig: true }, + { key: "app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app" }, + { key: "app/0.5.0/skus/jetkvm-v2/jetkvm_app" }, + { key: "app/0.6.0-beta.1/skus/jetkvm-v2/jetkvm_app" }, + { key: "app/0.6.0-beta.1/skus/jetkvm-v2-sdmmc/jetkvm_app" }, + + // System, with the recovery images that share its version. + { key: "system/0.2.0/system.tar" }, + { key: "system/0.2.0/update.img" }, + { key: "system/0.3.0/skus/jetkvm-v2/system.tar" }, + { key: "system/0.3.0/skus/jetkvm-v2/update.img" }, + { key: "system/0.3.0/skus/jetkvm-v2-sdmmc/system.tar" }, + { key: "system/0.3.0/skus/jetkvm-v2-sdmmc/update_sd.img.zip" }, + { key: "system/0.4.0-rc.1/skus/jetkvm-v2/system.tar" }, + { key: "system/0.4.0-rc.1/skus/jetkvm-v2/update.img" }, + { key: "system/0.4.0-rc.1/skus/jetkvm-v2-sdmmc/system.tar" }, + { key: "system/0.4.0-rc.1/skus/jetkvm-v2-sdmmc/update_sd.img.zip" }, + + // Mini. 0.4.0 uses the pre-SKU layout, which no Mini build may use. + // 0.5.4 has no wireless build. + { key: "mini/0.4.0/jetkvm-mini.bin" }, + { key: "mini/0.5.3/skus/jetkvm-mini-ethernet/jetkvm-mini.bin" }, + { key: "mini/0.5.3/skus/jetkvm-mini-ethernet/jetkvm-mini-full.bin" }, + { key: "mini/0.5.3/skus/jetkvm-mini-wireless/jetkvm-mini.bin" }, + { key: "mini/0.5.3/skus/jetkvm-mini-wireless/jetkvm-mini-full.bin" }, + { key: "mini/0.5.4/skus/jetkvm-mini-ethernet/jetkvm-mini.bin" }, + { key: "mini/0.5.4/skus/jetkvm-mini-ethernet/jetkvm-mini-full.bin" }, +]; + +/** Objects that are not releases: a folder whose name is not a version. */ +export const OTHER_OBJECTS = ["app/latest/README.txt"]; + +export function fixtureBody(key: string): Buffer { + return Buffer.from(`e2e fixture ${key}\n`); +} + +/** Every (type, version) folder with no prerelease tag, as the upload script would sync them. */ +export function stableVersions(): { type: string; version: string }[] { + const seen = new Map(); + for (const { key } of ARTIFACTS) { + const [type, version] = key.split("/"); + if (!version.includes("-")) seen.set(`${type}/${version}`, { type, version }); + } + return [...seen.values()]; +} + +/** + * Device IDs and their rollout bucket: parseInt(md5(id).slice(0, 8), 16) % 100. + * The values are fixed so that a change to the bucketing, which would move + * devices between rollout groups mid-rollout, fails the tests. + */ +export const DEVICE_IN_BUCKET = { + 0: "e2e-device-74", + 9: "e2e-device-167", + 49: "e2e-device-152", + 50: "e2e-device-28", + 81: "e2e-device-9", + 99: "e2e-device-19", +} as const; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..7ebd4ac --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,234 @@ +import { spawn, execFile, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; +import { promisify } from "node:util"; +import { + CreateBucketCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + PutObjectCommand, +} from "@aws-sdk/client-s3"; +import { PrismaClient } from "@prisma/client"; +import type { TestProject } from "vitest/node"; +import { + ARTIFACTS, + CDN_BASE, + OTHER_OBJECTS, + fixtureBody, + stableVersions, +} from "./fixtures"; +import { postSync, putArtifact, s3Client } from "./support"; + +/** + * Starts the built API (dist/index.js) the way the container does, against a + * Postgres database and an S3-compatible bucket that only the e2e run uses. + * + * E2E_DATABASE_URL default postgresql://jetkvm:jetkvm@localhost:5432/jetkvm_e2e + * E2E_S3_ENDPOINT default http://127.0.0.1:8333 (SeaweedFS from compose.development.yaml) + */ + +const DATABASE_URL = + process.env.E2E_DATABASE_URL ?? "postgresql://jetkvm:jetkvm@localhost:5432/jetkvm_e2e"; +// An IP host makes the AWS SDK use path-style requests, which the local S3 server needs. +const S3_ENDPOINT = process.env.E2E_S3_ENDPOINT ?? "http://127.0.0.1:8333"; +const BUCKET = "jetkvm-e2e"; +const APP_ORIGIN = "https://app.e2e.test"; + +declare module "vitest" { + export interface ProvidedContext { + apiUrl: string; + appOrigin: string; + databaseUrl: string; + s3Endpoint: string; + bucket: string; + cookieSecret: string; + syncToken: string; + iceServer: string; + } +} + +function assertLocal(url: string, what: string) { + const { hostname } = new URL(url); + if (!["localhost", "127.0.0.1", "::1"].includes(hostname)) { + throw new Error(`${what} must point at this machine, got ${hostname}`); + } +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +/** A new, empty database with every migration applied. */ +async function prepareDatabase() { + const name = new URL(DATABASE_URL).pathname.slice(1); + const admin = new URL(DATABASE_URL); + admin.pathname = "/postgres"; + + const prisma = new PrismaClient({ datasourceUrl: admin.toString() }); + try { + await prisma.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + await prisma.$executeRawUnsafe(`CREATE DATABASE "${name}"`); + } finally { + await prisma.$disconnect(); + } + + await promisify(execFile)("node_modules/.bin/prisma", ["migrate", "deploy"], { + env: { ...process.env, DATABASE_URL }, + }); +} + +/** The bucket, emptied and filled with the fixture releases. */ +async function prepareBucket() { + const s3 = s3Client(S3_ENDPOINT); + try { + await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); + } catch (error: any) { + if (!["BucketAlreadyOwnedByYou", "BucketAlreadyExists"].includes(error.name)) + throw error; + } + + for (;;) { + const { Contents = [] } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET })); + if (Contents.length === 0) break; + await s3.send( + new DeleteObjectsCommand({ + Bucket: BUCKET, + Delete: { Objects: Contents.map(({ Key }) => ({ Key })) }, + }), + ); + } + + const put = (Key: string, Body: Buffer | string) => + s3.send(new PutObjectCommand({ Bucket: BUCKET, Key, Body })); + await Promise.all([ + ...ARTIFACTS.map(({ key, sig }) => + Promise.all([ + putArtifact(s3, BUCKET, key, fixtureBody(key)), + sig ? put(`${key}.sig`, `signature of ${key}`) : undefined, + ]), + ), + ...OTHER_OBJECTS.map(key => put(key, fixtureBody(key))), + ]); +} + +async function waitForHealth(apiUrl: string, server: ChildProcess) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (server.exitCode !== null || server.signalCode !== null) { + throw new Error("The API exited during startup"); + } + try { + if ((await fetch(`${apiUrl}/healthz`)).ok) return; + } catch { + // not listening yet + } + await new Promise(r => setTimeout(r, 200)); + } + throw new Error("The API did not become healthy"); +} + +/** Registers every stable fixture version through the upload script's endpoint. */ +async function registerReleases(apiUrl: string, syncToken: string) { + for (const settled of stableVersions()) { + for (;;) { + const response = await postSync(apiUrl, syncToken, settled); + // 409 while the startup sync still runs + if (response.status === 409) { + await new Promise(r => setTimeout(r, 100)); + continue; + } + if (!response.ok) { + throw new Error( + `sync of ${settled.type} ${settled.version} failed: ${response.status}`, + ); + } + break; + } + } +} + +export default async function setup(project: TestProject) { + assertLocal(DATABASE_URL, "E2E_DATABASE_URL"); + assertLocal(S3_ENDPOINT, "E2E_S3_ENDPOINT"); + + const cookieSecret = randomBytes(16).toString("hex"); + const syncToken = randomBytes(16).toString("hex"); + const iceServer = "stun:stun.e2e.test:3478"; + + await Promise.all([prepareDatabase(), prepareBucket()]); + + const port = await freePort(); + const apiUrl = `http://127.0.0.1:${port}`; + const server = spawn(process.execPath, ["dist/index.js"], { + env: { + PATH: process.env.PATH, + // Keep a local .env out of the run + DOTENV_CONFIG_PATH: "/dev/null", + NODE_ENV: "production", + PORT: String(port), + DATABASE_URL, + API_HOSTNAME: apiUrl, + APP_HOSTNAME: APP_ORIGIN, + CORS_ORIGINS: APP_ORIGIN, + COOKIE_SECRET: cookieSecret, + GOOGLE_CLIENT_ID: "e2e-client-id", + GOOGLE_CLIENT_SECRET: "e2e-client-secret", + CLOUDFLARE_TURN_ID: "e2e", + CLOUDFLARE_TURN_TOKEN: "e2e", + R2_ENDPOINT: S3_ENDPOINT, + R2_ACCESS_KEY_ID: "e2e", + R2_SECRET_ACCESS_KEY: "e2e", + R2_BUCKET: BUCKET, + R2_CDN_URL: CDN_BASE, + REAL_IP_HEADER: "x-real-ip", + ICE_SERVERS: iceServer, + RELEASE_SYNC_TOKEN: syncToken, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + server.stdout!.on("data", chunk => (output += chunk)); + server.stderr!.on("data", chunk => (output += chunk)); + const log = () => output.split("\n").slice(-40).join("\n"); + + let stopping = false; + let crashed = false; + server.on("exit", (code, signal) => { + if (stopping) return; + crashed = true; + // Printed now, so the tests that fail next show why + console.error(`\nThe API exited (code ${code}, signal ${signal}):\n${log()}\n`); + }); + + try { + await waitForHealth(apiUrl, server); + await registerReleases(apiUrl, syncToken); + } catch (error) { + stopping = true; + server.kill(); + throw new Error(`${(error as Error).message}:\n${log()}`); + } + + project.provide("apiUrl", apiUrl); + project.provide("appOrigin", APP_ORIGIN); + project.provide("databaseUrl", DATABASE_URL); + project.provide("s3Endpoint", S3_ENDPOINT); + project.provide("bucket", BUCKET); + project.provide("cookieSecret", cookieSecret); + project.provide("syncToken", syncToken); + project.provide("iceServer", iceServer); + + return () => { + stopping = true; + server.kill("SIGTERM"); + if (crashed) throw new Error("The API exited during the e2e run"); + }; +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..5c2ea12 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,175 @@ +import { createHmac } from "node:crypto"; +import { DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { PrismaClient } from "@prisma/client"; +import { inject } from "vitest"; +import WebSocket from "ws"; +import { CDN_BASE } from "./fixtures"; +import { postSync, putArtifact, s3Client, sha256 } from "./support"; + +export { sha256 }; + +const apiUrl = inject("apiUrl"); +const bucket = inject("bucket"); +const s3 = s3Client(inject("s3Endpoint")); + +export const db = new PrismaClient({ datasourceUrl: inject("databaseUrl") }); + +export function api(path: string, init?: RequestInit) { + return fetch(`${apiUrl}${path}`, { redirect: "manual", ...init }); +} + +export const cdn = (key: string) => `${CDN_BASE}/${key}`; + +export async function objectBytes(key: string): Promise { + const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + return Buffer.from(await response.Body!.transformToByteArray()); +} + +export const uploadArtifact = (key: string, body: Buffer) => + putArtifact(s3, bucket, key, body); + +export async function removeArtifact(key: string) { + await Promise.all( + [key, `${key}.sha256`].map(Key => + s3.send(new DeleteObjectCommand({ Bucket: bucket, Key })), + ), + ); +} + +export const syncReleases = (body?: object) => + postSync(apiUrl, inject("syncToken"), body); + +/** The URL is on the CDN, and the object behind it has exactly this SHA-256. */ +export async function expectServable(url: string, hash: string) { + if (!url.startsWith(`${CDN_BASE}/`)) throw new Error(`${url} is not on ${CDN_BASE}`); + const actual = sha256(await objectBytes(url.slice(CDN_BASE.length + 1))); + if (actual !== hash) + throw new Error(`${url}: hash ${hash} does not match the object (${actual})`); +} + +type Rollouts = Record>; + +/** Sets every release to 0% except the ones listed, like an operator editing the table. */ +export async function setRollouts(rollouts: Rollouts) { + await db.release.updateMany({ data: { rolloutPercentage: 0 } }); + for (const [type, versions] of Object.entries(rollouts)) { + for (const [version, rolloutPercentage] of Object.entries(versions)) { + const { count } = await db.release.updateMany({ + where: { type, version }, + data: { rolloutPercentage }, + }); + if (count !== 1) + throw new Error(`No release ${type} ${version} to set to ${rolloutPercentage}%`); + } + } +} + +export async function getRelease(query: Record) { + const response = await api(`/releases?${new URLSearchParams(query)}`); + return { status: response.status, body: await response.json() }; +} + +/** + * A cookie-session cookie pair, signed like the API signs it. Only the + * WebSocket client route accepts the id_token without asking Google, so the + * signaling tests double as the check that this signature is right. + */ +export function sessionCookie(session: object, secret = inject("cookieSecret")) { + const value = Buffer.from(JSON.stringify(session)).toString("base64"); + const signature = createHmac("sha1", secret) + .update(`session=${value}`) + .digest("base64") + .replace(/\/|\+|=/g, c => ({ "/": "_", "+": "-", "=": "" })[c]!); + return `session=${value}; session.sig=${signature}`; +} + +/** A JWT that decodes but is not signed by Google. */ +export function unsignedIdToken(claims: Record) { + const part = (value: object) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const exp = Math.floor(Date.now() / 1000) + 3600; + return `${part({ alg: "RS256", kid: "e2e" })}.${part({ iss: "https://accounts.google.com", exp, ...claims })}.c2ln`; +} + +export async function createUserWithDevice( + googleId: string, + deviceId: string, + secretToken: string, +) { + await db.user.create({ + data: { googleId, device: { create: { id: deviceId, secretToken } } }, + }); +} + +function wsUrl(path: string) { + return `${apiUrl.replace(/^http/, "ws")}${path}`; +} + +/** Resolves with the open socket, or rejects with the HTTP status (or "closed") the server answered with. */ +export function openSocket( + path: string, + headers: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl(path), { headers }); + // Listen before the handshake completes: the server may send right after it + messages(ws); + ws.once("open", () => resolve(ws)); + ws.once("unexpected-response", (_req, res) => + reject(new Error(`rejected: ${res.statusCode}`)), + ); + ws.once("error", () => reject(new Error("rejected: closed"))); + }); +} + +type Inbox = { next(timeoutMs?: number): Promise }; +const inboxes = new WeakMap(); + +/** Collects JSON (or text) messages so tests can await the next one. */ +export function messages(ws: WebSocket): Inbox { + const existing = inboxes.get(ws); + if (existing) return existing; + const queue: unknown[] = []; + const waiters: ((value: unknown) => void)[] = []; + ws.on("message", data => { + const text = data.toString(); + let value: unknown = text; + try { + value = JSON.parse(text); + } catch { + // plain text such as "pong" + } + const waiter = waiters.shift(); + if (waiter) waiter(value); + else queue.push(value); + }); + const inbox: Inbox = { + next(timeoutMs = 5000): Promise { + if (queue.length > 0) return Promise.resolve(queue.shift()); + return new Promise((resolve, reject) => { + const waiter = (value: unknown) => { + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => { + waiters.splice(waiters.indexOf(waiter), 1); + reject(new Error("no message")); + }, timeoutMs); + waiters.push(waiter); + }); + }, + }; + inboxes.set(ws, inbox); + return inbox; +} + +export function closed(ws: WebSocket, timeoutMs = 5000): Promise { + if (ws.readyState === WebSocket.CLOSED) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("socket stayed open")), timeoutMs); + ws.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); +} diff --git a/e2e/http.test.ts b/e2e/http.test.ts new file mode 100644 index 0000000..3c839cd --- /dev/null +++ b/e2e/http.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, inject, it } from "vitest"; +import { api, sessionCookie, unsignedIdToken } from "./helpers"; + +const appOrigin = inject("appOrigin"); + +describe("health", () => { + it("answers / and /healthz", async () => { + const root = await api("/"); + expect(root.status).toBe(200); + expect(await root.text()).toBe("OK"); + + const health = await api("/healthz"); + expect(health.status).toBe(200); + const body = await health.json(); + expect(body.ready).toBe(true); + expect(Math.abs(Date.parse(body.time) - Date.now())).toBeLessThan(60_000); + }); +}); + +describe("response headers", () => { + it("include the security headers and do not name the framework", async () => { + const response = await api("/healthz"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN"); + expect(response.headers.get("strict-transport-security")).toMatch(/max-age=\d+/); + expect(response.headers.get("content-security-policy")).toContain( + "default-src 'self'", + ); + expect(response.headers.has("x-powered-by")).toBe(false); + }); +}); + +describe("CORS", () => { + const preflight = (origin: string) => + api("/devices", { + method: "OPTIONS", + headers: { origin, "access-control-request-method": "GET" }, + }); + + it("lets the web app call the API with its cookies", async () => { + const response = await preflight(appOrigin); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe(appOrigin); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + }); + + it.each(["https://evil.example", "https://app.e2e.test.evil.example", "null"])( + "does not let %s read responses", + async origin => { + const response = await preflight(origin); + expect(response.headers.has("access-control-allow-origin")).toBe(false); + + const simple = await api("/healthz", { headers: { origin } }); + expect(simple.headers.has("access-control-allow-origin")).toBe(false); + }, + ); +}); + +describe("routes that need a signed-in user", () => { + const routes: [string, string][] = [ + ["GET", "/me"], + ["GET", "/devices"], + ["GET", "/devices/some-device"], + ["PUT", "/devices/some-device"], + ["POST", "/webrtc/session"], + ["POST", "/webrtc/ice_config"], + ["POST", "/webrtc/turn_activity"], + ]; + + const call = (method: string, path: string, headers: Record = {}) => + api(path, { + method, + headers: { "content-type": "application/json", ...headers }, + body: method === "GET" ? undefined : "{}", + }); + + it.each(routes)("%s %s answers 401 without a session", async (method, path) => { + const response = await call(method, path); + expect(response.status).toBe(401); + expect((await response.json()).name).toBe("Unauthorized"); + }); + + it.each(routes)( + "%s %s answers 401 to an ID token Google did not sign", + async (method, path) => { + const cookie = sessionCookie({ + id_token: unsignedIdToken({ sub: "e2e-attacker", email: "a@e2e.test" }), + }); + expect((await call(method, path, { cookie })).status).toBe(401); + }, + ); + + it("ignores a session cookie signed with another secret", async () => { + const cookie = sessionCookie( + { id_token: unsignedIdToken({ sub: "e2e-attacker" }) }, + "not-the-secret", + ); + expect((await api("/devices", { headers: { cookie } })).status).toBe(401); + }); +}); + +describe("errors", () => { + it("answer an unknown route with 404", async () => { + expect((await api("/does-not-exist")).status).toBe(404); + }); + + it("do not include a stack trace", async () => { + const response = await api("/releases"); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + name: "BadRequestError", + message: "Device ID is required", + }); + }); +}); + +describe("sign-in and sign-out", () => { + it("the OIDC callback passes the query on to the cookie-carrying route, on this host", async () => { + const response = await api("/oidc/callback?code=abc&state=xyz"); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`URL='/oidc/callback_o?code=abc&state=xyz'`); + }); + + it("logout clears the session cookie", async () => { + const response = await api("/logout", { + method: "POST", + // As behind the TLS-terminating proxy, which the secure cookie needs + headers: { "x-forwarded-proto": "https", cookie: sessionCookie({ id_token: "x" }) }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ message: "Logged out" }); + const setCookie = response.headers.getSetCookie().join("\n"); + expect(setCookie).toMatch(/session=;/); + expect(setCookie).toMatch(/expires=Thu, 01 Jan 1970/i); + }); +}); diff --git a/e2e/release-sync.test.ts b/e2e/release-sync.test.ts new file mode 100644 index 0000000..51fc6db --- /dev/null +++ b/e2e/release-sync.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, inject, it } from "vitest"; +import { + api, + cdn, + db, + removeArtifact, + sha256, + syncReleases, + uploadArtifact, +} from "./helpers"; + +async function registered() { + return db.release.findMany({ + select: { + type: true, + version: true, + artifacts: { select: { url: true, compatibleSkus: true }, orderBy: { id: "asc" } }, + }, + orderBy: [{ type: "asc" }, { id: "asc" }], + }); +} + +describe("release registration", () => { + it("registers each stable version in the bucket with the SKUs it has files for", async () => { + expect(await registered()).toEqual([ + { + type: "app", + version: "0.3.0", + artifacts: [{ url: cdn("app/0.3.0/jetkvm_app"), compatibleSkus: ["jetkvm-v2"] }], + }, + { + type: "app", + version: "0.4.0", + artifacts: [ + { + url: cdn("app/0.4.0/skus/jetkvm-v2/jetkvm_app"), + compatibleSkus: ["jetkvm-v2"], + }, + { + url: cdn("app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app"), + compatibleSkus: ["jetkvm-v2-sdmmc"], + }, + ], + }, + { + type: "app", + version: "0.5.0", + artifacts: [ + { + url: cdn("app/0.5.0/skus/jetkvm-v2/jetkvm_app"), + compatibleSkus: ["jetkvm-v2"], + }, + ], + }, + { + type: "mini", + version: "0.5.3", + artifacts: [ + { + url: cdn("mini/0.5.3/skus/jetkvm-mini-ethernet/jetkvm-mini.bin"), + compatibleSkus: ["jetkvm-mini-ethernet"], + }, + { + url: cdn("mini/0.5.3/skus/jetkvm-mini-wireless/jetkvm-mini.bin"), + compatibleSkus: ["jetkvm-mini-wireless"], + }, + ], + }, + { + type: "mini", + version: "0.5.4", + artifacts: [ + { + url: cdn("mini/0.5.4/skus/jetkvm-mini-ethernet/jetkvm-mini.bin"), + compatibleSkus: ["jetkvm-mini-ethernet"], + }, + ], + }, + { + type: "system", + version: "0.2.0", + artifacts: [ + { url: cdn("system/0.2.0/system.tar"), compatibleSkus: ["jetkvm-v2"] }, + ], + }, + { + type: "system", + version: "0.3.0", + artifacts: [ + { + url: cdn("system/0.3.0/skus/jetkvm-v2/system.tar"), + compatibleSkus: ["jetkvm-v2"], + }, + { + url: cdn("system/0.3.0/skus/jetkvm-v2-sdmmc/system.tar"), + compatibleSkus: ["jetkvm-v2-sdmmc"], + }, + ], + }, + ]); + }); + + describe("of a new upload", () => { + const keys = [ + "app/0.4.5/skus/jetkvm-v2/jetkvm_app", + "app/0.4.5/skus/jetkvm-v2-sdmmc/jetkvm_app", + ]; + + afterEach(async () => { + await db.release.deleteMany({ where: { type: "app", version: "0.4.5" } }); + await Promise.all(keys.map(removeArtifact)); + }); + + it("waits for the upload to settle unless the uploader says it is complete, then starts at 0%", async () => { + const bodies = keys.map(key => Buffer.from(`new upload ${key}`)); + await Promise.all(keys.map((key, i) => uploadArtifact(key, bodies[i]))); + + // The objects were just written, so an unattended run leaves the version for later + const unattended = await syncReleases(); + expect(unattended.status).toBe(200); + expect(await unattended.json()).toMatchObject({ created: 0 }); + expect(await db.release.count({ where: { type: "app", version: "0.4.5" } })).toBe( + 0, + ); + + const vouched = await syncReleases({ type: "app", version: "0.4.5" }); + expect(await vouched.json()).toMatchObject({ created: 1 }); + + const release = await db.release.findUniqueOrThrow({ + where: { version_type: { version: "0.4.5", type: "app" } }, + include: { artifacts: { orderBy: { id: "asc" } } }, + }); + expect(release.rolloutPercentage).toBe(0); + expect( + release.artifacts.map(({ url, hash, compatibleSkus }) => ({ + url, + hash, + compatibleSkus, + })), + ).toEqual([ + { url: cdn(keys[0]), hash: sha256(bodies[0]), compatibleSkus: ["jetkvm-v2"] }, + { + url: cdn(keys[1]), + hash: sha256(bodies[1]), + compatibleSkus: ["jetkvm-v2-sdmmc"], + }, + ]); + }); + }); + + it("never changes a release that is already registered", async () => { + await db.release.updateMany({ + where: { type: "app", version: "0.4.0" }, + data: { rolloutPercentage: 37 }, + }); + const before = await db.release.findFirstOrThrow({ + where: { type: "app", version: "0.4.0" }, + include: { artifacts: true }, + }); + + const response = await syncReleases({ type: "app", version: "0.4.0" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ created: 0 }); + + const after = await db.release.findFirstOrThrow({ + where: { type: "app", version: "0.4.0" }, + include: { artifacts: true }, + }); + expect(after).toEqual(before); + }); +}); + +describe("POST /releases/sync", () => { + const token = inject("syncToken"); + + it.each([ + ["no Authorization header", {}], + ["a wrong token", { authorization: "Bearer not-the-token" }], + [ + "the token in another letter case", + { authorization: `Bearer ${token.toUpperCase()}` }, + ], + ["the token under another scheme", { authorization: `Basic ${token}` }], + ])("rejects %s with 401", async (_name, headers) => { + const response = await api("/releases/sync", { method: "POST", headers }); + expect(response.status).toBe(401); + }); + + it("accepts the token with the scheme name in any case", async () => { + const response = await api("/releases/sync", { + method: "POST", + headers: { authorization: `bearer ${token}` }, + }); + expect(response.status).toBe(200); + }); + + it.each([ + ["a type without a version", { type: "app" }], + ["a version without a type", { version: "0.4.0" }], + ["an unknown type", { type: "firmware", version: "0.4.0" }], + ])("rejects %s with 400", async (_name, body) => { + expect((await syncReleases(body)).status).toBe(400); + }); +}); diff --git a/e2e/releases.test.ts b/e2e/releases.test.ts new file mode 100644 index 0000000..c589786 --- /dev/null +++ b/e2e/releases.test.ts @@ -0,0 +1,320 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEVICE_IN_BUCKET } from "./fixtures"; +import { + api, + cdn, + db, + expectServable, + getRelease, + objectBytes, + removeArtifact, + setRollouts, + syncReleases, + uploadArtifact, +} from "./helpers"; + +// Selection rules are covered by test/releases.test.ts against a mocked S3. +// These tests cover what only a real bucket, a real sync and HTTP show: the +// files a device is sent exist and match the hash it is told to check. + +const V2 = "jetkvm-v2"; +const SDMMC = "jetkvm-v2-sdmmc"; +const MINI_ETH = "jetkvm-mini-ethernet"; +const MINI_WIFI = "jetkvm-mini-wireless"; + +/** The offered versions and URLs, after checking every URL serves bytes with the offered hash. */ +async function offered(query: Record) { + const { status, body } = await getRelease(query); + expect(status, JSON.stringify(body)).toBe(200); + if (body.appUrl) await expectServable(body.appUrl, body.appHash); + if (body.systemUrl) await expectServable(body.systemUrl, body.systemHash); + return { + appVersion: body.appVersion, + appUrl: body.appUrl, + systemVersion: body.systemVersion, + systemUrl: body.systemUrl, + }; +} + +const device = (bucket: keyof typeof DEVICE_IN_BUCKET, sku = V2) => ({ + deviceId: DEVICE_IN_BUCKET[bucket], + sku, +}); + +describe("an update check", () => { + it("gets, for each SKU, files that exist and match the hashes it must verify", async () => { + await setRollouts({ + app: { "0.4.0": 100 }, + system: { "0.3.0": 100 }, + mini: { "0.5.3": 100, "0.5.4": 100 }, + }); + + expect(await offered(device(9, V2))).toEqual({ + appVersion: "0.4.0", + appUrl: cdn("app/0.4.0/skus/jetkvm-v2/jetkvm_app"), + systemVersion: "0.3.0", + systemUrl: cdn("system/0.3.0/skus/jetkvm-v2/system.tar"), + }); + expect(await offered(device(9, SDMMC))).toEqual({ + appVersion: "0.4.0", + appUrl: cdn("app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app"), + systemVersion: "0.3.0", + systemUrl: cdn("system/0.3.0/skus/jetkvm-v2-sdmmc/system.tar"), + }); + expect(await offered(device(9, MINI_ETH))).toEqual({ + appVersion: undefined, + appUrl: undefined, + systemVersion: "0.5.4", + systemUrl: cdn("mini/0.5.4/skus/jetkvm-mini-ethernet/jetkvm-mini.bin"), + }); + // 0.5.4 has no wireless build + expect(await offered(device(9, MINI_WIFI))).toMatchObject({ + systemVersion: "0.5.3", + systemUrl: cdn("mini/0.5.3/skus/jetkvm-mini-wireless/jetkvm-mini.bin"), + }); + }); + + it("gets a signature URL only where the bucket has a .sig file", async () => { + await setRollouts({ app: { "0.4.0": 100 }, system: { "0.3.0": 100 } }); + + const v2 = (await getRelease(device(9, V2))).body; + expect(v2.appSigUrl).toBe(cdn("app/0.4.0/skus/jetkvm-v2/jetkvm_app.sig")); + expect( + (await objectBytes("app/0.4.0/skus/jetkvm-v2/jetkvm_app.sig")).length, + ).toBeGreaterThan(0); + expect(v2).not.toHaveProperty("systemSigUrl"); + expect((await getRelease(device(9, SDMMC))).body).not.toHaveProperty("appSigUrl"); + }); + + it("puts devices into a staged rollout by a bucket that does not change between versions of the API", async () => { + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 50 }, system: { "0.3.0": 100 } }); + expect((await offered(device(0))).appVersion).toBe("0.5.0"); + expect((await offered(device(49))).appVersion).toBe("0.5.0"); + expect((await offered(device(50))).appVersion).toBe("0.4.0"); + expect((await offered(device(99))).appVersion).toBe("0.4.0"); + + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 81 }, system: { "0.3.0": 100 } }); + expect((await offered(device(81))).appVersion).toBe("0.4.0"); + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 82 }, system: { "0.3.0": 100 } }); + expect((await offered(device(81))).appVersion).toBe("0.5.0"); + }); + + it("never sends a device a file built for other hardware", async () => { + // 0.5.0 has no SDMMC build; 0.3.0 and 0.2.0 predate the SKU layout and are JetKVM v2 only + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 100 }, system: { "0.3.0": 100 } }); + expect((await offered(device(9, SDMMC))).appUrl).toBe( + cdn("app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app"), + ); + + await setRollouts({ app: { "0.3.0": 100 }, system: { "0.2.0": 100 } }); + expect(await offered(device(9, V2))).toMatchObject({ + appUrl: cdn("app/0.3.0/jetkvm_app"), + systemUrl: cdn("system/0.2.0/system.tar"), + }); + expect((await getRelease(device(9, SDMMC))).status).toBe(404); + }); + + it("on the prerelease channel gets the newest build in the bucket, whatever the rollout", async () => { + await setRollouts({}); + expect(await offered({ ...device(99, V2), prerelease: "true" })).toEqual({ + appVersion: "0.6.0-beta.1", + appUrl: cdn("app/0.6.0-beta.1/skus/jetkvm-v2/jetkvm_app"), + systemVersion: "0.4.0-rc.1", + systemUrl: cdn("system/0.4.0-rc.1/skus/jetkvm-v2/system.tar"), + }); + }); +}); + +describe("a release from upload to full rollout", () => { + const keys = [ + "app/0.4.6/skus/jetkvm-v2/jetkvm_app", + "app/0.4.6/skus/jetkvm-v2-sdmmc/jetkvm_app", + ]; + + afterEach(async () => { + await db.release.deleteMany({ where: { type: "app", version: "0.4.6" } }); + await Promise.all(keys.map(removeArtifact)); + }); + + const setApp046 = (rolloutPercentage: number) => + db.release.updateMany({ + where: { type: "app", version: "0.4.6" }, + data: { rolloutPercentage }, + }); + + it("reaches no device at 0%, the devices in the bucket while staged, and every device at 100%", async () => { + // app 0.5.0 stays registered at 0%: a newer unreleased version must not get in the way + await setRollouts({ app: { "0.4.0": 100 }, system: { "0.3.0": 100 } }); + await Promise.all( + keys.map(key => uploadArtifact(key, Buffer.from(`release ${key}`))), + ); + expect( + await (await syncReleases({ type: "app", version: "0.4.6" })).json(), + ).toMatchObject({ + created: 1, + }); + + // Registered at 0%: every device keeps 0.4.0 + for (const bucket of [0, 9, 81, 99] as const) { + expect((await offered(device(bucket))).appVersion).toBe("0.4.0"); + } + + await setApp046(30); + expect(await offered(device(9))).toMatchObject({ + appVersion: "0.4.6", + appUrl: cdn(keys[0]), + }); + expect(await offered(device(9, SDMMC))).toMatchObject({ + appVersion: "0.4.6", + appUrl: cdn(keys[1]), + }); + expect((await offered(device(81))).appVersion).toBe("0.4.0"); + expect((await offered(device(81, SDMMC))).appVersion).toBe("0.4.0"); + + await setApp046(100); + for (const bucket of [0, 81, 99] as const) { + expect((await offered(device(bucket))).appVersion).toBe("0.4.6"); + expect((await offered(device(bucket, SDMMC))).appUrl).toBe(cdn(keys[1])); + } + }); +}); + +describe("the first staged release that has a build for a new SKU", () => { + // app 0.3.0 predates the SKU layout (JetKVM v2 only); 0.4.0 is the first with an SDMMC build + it("reaches the new SKU's devices in the bucket, and the rest get 404 until it is rolled out", async () => { + await setRollouts({ app: { "0.3.0": 100, "0.4.0": 30 }, system: { "0.3.0": 100 } }); + + expect(await offered(device(9, SDMMC))).toMatchObject({ + appVersion: "0.4.0", + appUrl: cdn("app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app"), + }); + const outside = await getRelease(device(81, SDMMC)); + expect(outside.status).toBe(404); + expect(outside.body.name).toBe("NotFoundError"); + + // The original JetKVM is not affected + expect((await offered(device(9, V2))).appVersion).toBe("0.4.0"); + expect((await offered(device(81, V2))).appUrl).toBe(cdn("app/0.3.0/jetkvm_app")); + }); +}); + +describe("an update check that pins one part", () => { + it("gets the other part as a background check would: rolled out, with a build for the SKU", async () => { + // system 0.3.0 is registered but not released; app 0.5.0 has no SDMMC build + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 100 }, system: { "0.2.0": 100 } }); + + expect(await offered({ ...device(9, V2), appVersion: "0.5.0" })).toMatchObject({ + appVersion: "0.5.0", + systemVersion: "0.2.0", + }); + expect(await offered({ ...device(9, V2), systemVersion: "0.3.0" })).toMatchObject({ + appVersion: "0.5.0", + systemVersion: "0.3.0", + }); + + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 100 }, system: { "0.3.0": 100 } }); + expect(await offered({ ...device(9, SDMMC), systemVersion: "0.3.0" })).toMatchObject({ + appVersion: "0.4.0", + appUrl: cdn("app/0.4.0/skus/jetkvm-v2-sdmmc/jetkvm_app"), + systemVersion: "0.3.0", + }); + }); + + it("follows the device's rollout bucket for the other part", async () => { + await setRollouts({ app: { "0.4.0": 100, "0.5.0": 50 }, system: { "0.3.0": 100 } }); + expect((await offered({ ...device(9), systemVersion: "0.3.0" })).appVersion).toBe( + "0.5.0", + ); + expect((await offered({ ...device(81), systemVersion: "0.3.0" })).appVersion).toBe( + "0.4.0", + ); + }); +}); + +describe("a SKU missing from the newest upload", () => { + // app 0.5.0 has no SDMMC build; mini 0.5.4 has no wireless build + + it("does not hide the newest version that has it on the prerelease channel", async () => { + await setRollouts({}); + expect(await offered({ ...device(9, MINI_WIFI), prerelease: "true" })).toMatchObject({ + systemVersion: "0.5.3", + systemUrl: cdn("mini/0.5.3/skus/jetkvm-mini-wireless/jetkvm-mini.bin"), + }); + }); + + it.each([ + "/releases/app/latest?sku=jetkvm-v2-sdmmc", + "/releases/system_recovery/latest?sku=jetkvm-mini-wireless", + ])("makes %s answer 404, not an older file", async path => { + expect((await api(path)).status).toBe(404); + }); +}); + +describe("an update check that cannot be answered", () => { + it("gets 404 while every release is registered at 0%", async () => { + await setRollouts({}); + for (const sku of [V2, SDMMC, MINI_ETH]) { + expect((await getRelease(device(9, sku))).status).toBe(404); + } + }); + + it("gets 404 for a pinned version that does not exist", async () => { + await setRollouts({ app: { "0.4.0": 100 }, system: { "0.3.0": 100 } }); + const { status } = await getRelease({ + ...device(9), + appVersion: "9.9.9", + systemVersion: "0.3.0", + }); + expect(status).toBe(404); + }); + + it.each([ + ["no device ID", ""], + ["an empty device ID", "deviceId="], + ["an unknown SKU", `deviceId=${DEVICE_IN_BUCKET[9]}&sku=jetkvm-v3`], + ])("with %s gets 400 and no release", async (_name, query) => { + await setRollouts({ app: { "0.4.0": 100 }, system: { "0.3.0": 100 } }); + const response = await api(`/releases?${query}`); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.name).toBe("BadRequestError"); + expect(body).not.toHaveProperty("appUrl"); + expect(body).not.toHaveProperty("stack"); + }); +}); + +describe("the latest-artifact redirects used by build tooling", () => { + it.each([ + ["/releases/app/latest", "app/0.5.0/skus/jetkvm-v2/jetkvm_app"], + [ + "/releases/app/latest?prerelease=true", + "app/0.6.0-beta.1/skus/jetkvm-v2/jetkvm_app", + ], + ["/releases/system_recovery/latest", "system/0.3.0/skus/jetkvm-v2/update.img"], + [ + "/releases/system_recovery/latest?sku=jetkvm-v2-sdmmc", + "system/0.3.0/skus/jetkvm-v2-sdmmc/update_sd.img.zip", + ], + [ + "/releases/system_recovery/latest?prerelease=true", + "system/0.4.0-rc.1/skus/jetkvm-v2/update.img", + ], + [ + "/releases/system_recovery/latest?sku=jetkvm-mini-ethernet", + "mini/0.5.4/skus/jetkvm-mini-ethernet/jetkvm-mini-full.bin", + ], + ])("%s redirects to %s, which exists", async (path, key) => { + const response = await api(path); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(cdn(key)); + expect((await objectBytes(key)).length).toBeGreaterThan(0); + }); + + it.each([ + "/releases/app/latest?sku=jetkvm-mini-ethernet", + "/releases/app/latest?sku=jetkvm-v3", + "/releases/system_recovery/latest?sku=jetkvm-v3", + ])("%s is a bad request", async path => { + expect((await api(path)).status).toBe(400); + }); +}); diff --git a/e2e/signaling.test.ts b/e2e/signaling.test.ts new file mode 100644 index 0000000..f79e10b --- /dev/null +++ b/e2e/signaling.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, inject, it } from "vitest"; +import { connect } from "node:net"; +import type WebSocket from "ws"; +import { + api, + closed, + createUserWithDevice, + db, + messages, + openSocket, + sessionCookie, + unsignedIdToken, +} from "./helpers"; + +const OWNER = "e2e-sig-owner"; +const OTHER_USER = "e2e-sig-other"; +const DEVICE = "e2e-sig-device"; +const SECRET = "e2e-sig-secret"; +const DEVICE_IP = "203.0.113.7"; + +const sockets: WebSocket[] = []; +async function track(socket: Promise) { + const ws = await socket; + sockets.push(ws); + return ws; +} + +function connectDevice(headers: Record = {}) { + return track( + openSocket("/", { + authorization: `Bearer ${SECRET}`, + "x-device-id": DEVICE, + "x-app-version": "0.5.0", + "x-device-sku": "jetkvm-v2-sdmmc", + "x-real-ip": DEVICE_IP, + ...headers, + }), + ); +} + +const idTokenFor = (sub: string) => unsignedIdToken({ sub }); + +function connectClient(sub = OWNER, deviceId = DEVICE) { + return track( + openSocket(`/webrtc/signaling/client?id=${deviceId}`, { + cookie: sessionCookie({ id_token: idTokenFor(sub) }), + }), + ); +} + +beforeEach(async () => { + await db.device.deleteMany({ where: { id: DEVICE } }); + await db.user.deleteMany({ where: { googleId: { in: [OWNER, OTHER_USER] } } }); + await createUserWithDevice(OWNER, DEVICE, SECRET); + await db.user.create({ data: { googleId: OTHER_USER } }); +}); + +afterEach(async () => { + await Promise.all( + sockets.splice(0).map(ws => { + ws.close(); + return closed(ws).catch(() => undefined); + }), + ); +}); + +describe("device connection", () => { + it.each([ + ["no token", { authorization: "" }], + ["a wrong token", { authorization: "Bearer wrong" }], + ["another device's ID", { "x-device-id": "e2e-sig-someone-else" }], + ["no device ID", { "x-device-id": "" }], + ])("is refused with %s", async (_name, headers) => { + await expect(connectDevice(headers)).rejects.toThrow(/rejected/); + }); + + it("records when the device was last seen after it disconnects", async () => { + const device = await connectDevice(); + device.close(); + await closed(device); + + await expect + .poll( + async () => + (await db.device.findUniqueOrThrow({ where: { id: DEVICE } })).lastSeen, + ) + .not.toBeNull(); + }); + + it("replaces the earlier connection when the same device connects again", async () => { + const first = await connectDevice(); + const firstClosed = closed(first); + await connectDevice({ "x-app-version": "0.5.1" }); + await firstClosed; + + const client = messages(await connectClient()); + expect(await client.next()).toEqual({ + type: "device-metadata", + data: { deviceVersion: "0.5.1", deviceSku: "jetkvm-v2-sdmmc" }, + }); + }); +}); + +describe("client connection", () => { + it("is refused without a session", async () => { + await expect( + track(openSocket(`/webrtc/signaling/client?id=${DEVICE}`)), + ).rejects.toThrow(/rejected/); + }); + + it("is refused with a session cookie signed with another secret", async () => { + await connectDevice(); + const cookie = sessionCookie({ id_token: idTokenFor(OWNER) }, "not-the-secret"); + await expect( + track(openSocket(`/webrtc/signaling/client?id=${DEVICE}`, { cookie })), + ).rejects.toThrow(/rejected/); + }); + + it("is refused for a device that belongs to another user", async () => { + await connectDevice(); + await expect(connectClient(OTHER_USER)).rejects.toThrow(/rejected/); + }); + + it("answers 404 while the device is offline", async () => { + await expect(connectClient()).rejects.toThrow("rejected: 404"); + }); + + it("survives an upgrade request whose target is not a valid URL", async () => { + const { hostname, port } = new URL(inject("apiUrl")); + await new Promise(resolve => { + const socket = connect(Number(port), hostname, () => + socket.write( + "GET http://[ HTTP/1.1\r\nHost: e2e\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ), + ); + socket.on("close", () => resolve()); + socket.on("error", () => resolve()); + }); + expect((await api("/healthz")).status).toBe(200); + }); + + it("is refused on an unknown path", async () => { + await expect(track(openSocket("/webrtc/signaling/other"))).rejects.toThrow( + /rejected/, + ); + }); +}); + +describe("signaling between the owner and the device", () => { + it("tells the client which firmware and hardware the device runs", async () => { + await connectDevice(); + const client = messages(await connectClient()); + expect(await client.next()).toEqual({ + type: "device-metadata", + data: { deviceVersion: "0.5.0", deviceSku: "jetkvm-v2-sdmmc" }, + }); + }); + + it("reports the original JetKVM for a device that sends an unknown SKU", async () => { + await connectDevice({ "x-device-sku": "jetkvm-v9" }); + const client = messages(await connectClient()); + expect((await client.next()).data.deviceSku).toBe("jetkvm-v2"); + }); + + it("forwards the offer with the device's public IP, ICE servers and the user's ID token", async () => { + const device = messages(await connectDevice()); + const clientWs = await connectClient(); + const client = messages(clientWs); + await client.next(); // device-metadata + + clientWs.send(JSON.stringify({ type: "offer", data: { sd: "offer-sdp" } })); + expect(await device.next()).toEqual({ + type: "offer", + data: { + sd: "offer-sdp", + ip: DEVICE_IP, + iceServers: [inject("iceServer")], + OidcGoogle: idTokenFor(OWNER), + }, + }); + }); + + it("relays the answer and ICE candidates both ways", async () => { + const deviceWs = await connectDevice(); + const device = messages(deviceWs); + const clientWs = await connectClient(); + const client = messages(clientWs); + await client.next(); // device-metadata + + deviceWs.send(JSON.stringify({ type: "answer", data: "answer-sdp" })); + expect(await client.next()).toEqual({ type: "answer", data: "answer-sdp" }); + + deviceWs.send( + JSON.stringify({ type: "new-ice-candidate", data: { candidate: "from-device" } }), + ); + expect(await client.next()).toEqual({ + type: "new-ice-candidate", + data: { candidate: "from-device" }, + }); + + clientWs.send( + JSON.stringify({ type: "new-ice-candidate", data: { candidate: "from-client" } }), + ); + expect(await device.next()).toEqual({ + type: "new-ice-candidate", + data: { candidate: "from-client" }, + }); + }); + + it("answers the client's keepalive", async () => { + await connectDevice(); + const clientWs = await connectClient(); + const client = messages(clientWs); + await client.next(); // device-metadata + + clientWs.send("ping"); + expect(await client.next()).toBe("pong"); + }); + + it("closes the client when the device goes away", async () => { + const deviceWs = await connectDevice(); + const clientWs = await connectClient(); + const clientClosed = closed(clientWs); + + deviceWs.close(); + await clientClosed; + }); +}); diff --git a/e2e/support.ts b/e2e/support.ts new file mode 100644 index 0000000..f23a972 --- /dev/null +++ b/e2e/support.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; + +// Shared by global setup and the tests. Nothing here may call inject(), which +// only works inside the test runner. + +export const sha256 = (bytes: Buffer | string) => + createHash("sha256").update(bytes).digest("hex"); + +export function s3Client(endpoint: string) { + return new S3Client({ + endpoint, + region: "auto", + credentials: { accessKeyId: "e2e", secretAccessKey: "e2e" }, + }); +} + +/** Uploads an artifact and its .sha256 sidecar, as the release script does. */ +export async function putArtifact( + s3: S3Client, + bucket: string, + key: string, + body: Buffer, +) { + await Promise.all([ + s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body })), + s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${key}.sha256`, + Body: `${sha256(body)}\n`, + }), + ), + ]); +} + +/** POST /releases/sync as the upload script calls it. */ +export function postSync(apiUrl: string, token: string, body?: object) { + return fetch(`${apiUrl}/releases/sync`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} diff --git a/package-lock.json b/package-lock.json index 8579101..fe9214e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,6 @@ "dependencies": { "@aws-sdk/client-s3": "^3.654.0", "@prisma/client": "^5.13.0", - "@tsconfig/node22": "^22.0.0", - "@types/cookie-session": "^2.0.49", - "@types/cors": "^2.8.17", - "@types/node": "^20.12.10", - "@types/ws": "^8.5.10", "cookie-session": "^2.1.0", "cors": "^2.8.5", "dotenv": "^16.4.7", @@ -27,22 +22,27 @@ "openid-client": "^5.6.5", "prisma": "^5.13.0", "semver": "^7.6.3", - "ts-node": "^10.9.2", - "typescript": "^5.4.5", "ws": "^8.17.1", "zod": "^4.3.6" }, "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/cookie-session": "^2.0.49", + "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/lru-cache": "^7.10.9", + "@types/node": "^20.12.10", "@types/semver": "^7.5.8", + "@types/ws": "^8.5.10", "@vitest/coverage-v8": "^4.0.18", "aws-sdk-client-mock": "^4.1.0", "prettier": "3.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.4.5", "vitest": "^4.0.18" }, "engines": { - "node": "22.21.0" + "node": ">=22.23.4 <23" }, "optionalDependencies": { "bufferutil": "^4.0.8" @@ -964,6 +964,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1417,6 +1418,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -1425,12 +1427,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2541,32 +2545,38 @@ "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==" + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true }, "node_modules/@tsconfig/node22": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.0.tgz", - "integrity": "sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg==" + "integrity": "sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg==", + "dev": true }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -2587,6 +2597,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -2595,6 +2606,7 @@ "version": "2.0.49", "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", + "dev": true, "dependencies": { "@types/express": "*", "@types/keygrip": "*" @@ -2604,6 +2616,7 @@ "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -2626,6 +2639,7 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -2637,6 +2651,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2649,6 +2664,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, "license": "MIT" }, "node_modules/@types/http-proxy": { @@ -2662,7 +2678,8 @@ "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", - "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==" + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true }, "node_modules/@types/lru-cache": { "version": "7.10.9", @@ -2686,12 +2703,14 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/semver": { @@ -2704,6 +2723,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2713,6 +2733,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -2740,6 +2761,7 @@ "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -2875,6 +2897,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2886,6 +2909,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -2893,7 +2917,8 @@ "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true }, "node_modules/assertion-error": { "version": "2.0.1", @@ -3157,7 +3182,8 @@ "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true }, "node_modules/debug": { "version": "4.3.6", @@ -3196,6 +3222,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, "engines": { "node": ">=0.3.1" } @@ -3865,7 +3892,8 @@ "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -4689,6 +4717,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4767,6 +4796,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4811,7 +4841,8 @@ "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true }, "node_modules/vary": { "version": "1.1.2", @@ -5084,6 +5115,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index 8374041..655c39f 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "jetkvm-cloud-api", "version": "1.0.0", "description": "JetKVM Cloud API and Websocket Server", - "main": "dist/src/index.js", + "main": "dist/index.js", "scripts": { - "start": "NODE_ENV=production node -r ts-node/register ./src/index.ts", + "start": "NODE_ENV=production node dist/index.js", "dev": "NODE_ENV=development node --watch --watch-path=./src --env-file=.env.development -r ts-node/register ./src/index.ts", "dev:debug": "NODE_ENV=development node --watch --watch-path=./src --env-file=.env.development -r ts-node/register ./src/index.ts --inspect-brk", "prisma-dev": "prisma generate --watch", @@ -16,7 +16,9 @@ "build": "tsc", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts", + "compare": "node scripts/compare-api.mts" }, "engines": { "node": ">=22.23.4 <23" @@ -27,11 +29,6 @@ "dependencies": { "@aws-sdk/client-s3": "^3.654.0", "@prisma/client": "^5.13.0", - "@tsconfig/node22": "^22.0.0", - "@types/cookie-session": "^2.0.49", - "@types/cors": "^2.8.17", - "@types/node": "^20.12.10", - "@types/ws": "^8.5.10", "cookie-session": "^2.1.0", "cors": "^2.8.5", "dotenv": "^16.4.7", @@ -43,8 +40,6 @@ "openid-client": "^5.6.5", "prisma": "^5.13.0", "semver": "^7.6.3", - "ts-node": "^10.9.2", - "typescript": "^5.4.5", "ws": "^8.17.1", "zod": "^4.3.6" }, @@ -52,12 +47,19 @@ "bufferutil": "^4.0.8" }, "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/cookie-session": "^2.0.49", + "@types/cors": "^2.8.17", "@types/express": "^5.0.6", "@types/lru-cache": "^7.10.9", + "@types/node": "^20.12.10", "@types/semver": "^7.5.8", + "@types/ws": "^8.5.10", "@vitest/coverage-v8": "^4.0.18", "aws-sdk-client-mock": "^4.1.0", "prettier": "3.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.4.5", "vitest": "^4.0.18" } } diff --git a/scripts/compare-api.mts b/scripts/compare-api.mts new file mode 100644 index 0000000..bce9df4 --- /dev/null +++ b/scripts/compare-api.mts @@ -0,0 +1,538 @@ +/** + * Sends the same requests to two API deployments and reports where the + * answers differ, grouped by cause. + * + * npm run compare -- --candidate [options] + * + * Only safe requests are sent: GETs, CORS preflights, and POSTs that the + * API must reject before any handler runs. + */ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline/promises"; +import { parseArgs } from "node:util"; + +const ALIASES: Record = { + local: "http://localhost:3000", + staging: "https://staging-api.jetkvm.com", + production: "https://api.jetkvm.com", +}; + +const SKUS = [ + "jetkvm-v2", + "jetkvm-v2-sdmmc", + "jetkvm-mini-ethernet", + "jetkvm-mini-wireless", +]; +const MINI_SKUS = new Set(["jetkvm-mini-ethernet", "jetkvm-mini-wireless"]); +const AREAS = ["releases", "redirects", "auth", "http", "replay"] as const; +type Area = (typeof AREAS)[number]; + +const COMPARED_HEADERS = [ + "content-type", + "location", + "access-control-allow-origin", + "access-control-allow-credentials", + "x-content-type-options", + "x-frame-options", + "strict-transport-security", + "x-powered-by", +]; + +const USAGE = `Usage: npm run compare -- --candidate [options] + + --candidate API under test (local = ${ALIASES.local}, staging = ${ALIASES.staging}) + --reference API to compare against (default ${ALIASES.production}) + --only Comma-separated: ${AREAS.join(", ")} (default: all but replay) + --sku Only this SKU; repeat for more (default: ${SKUS.join(", ")}) + --device Add a device ID; repeat for more (default: one per 5% rollout bucket) + --requests Also replay these GET paths, one per line (# starts a comment) + --json Write every request and its result to this file + --concurrency Requests in flight per API (default 8) + --examples Examples shown per group (default 3) +`; + +interface Probe { + area: Area; + /** Requests of one kind share a label; differences are grouped by it. */ + label: string; + method: string; + path: string; + headers?: Record; + /** Pinned requests do not depend on the rollout table. */ + rolloutDependent?: boolean; +} + +interface Answer { + status: number | string; + headers: Record; + body: unknown; +} + +interface Difference { + field: string; + reference: unknown; + candidate: unknown; +} + +// ---------------------------------------------------------------- arguments + +const { values: args } = parseArgs({ + options: { + candidate: { type: "string" }, + reference: { type: "string", default: ALIASES.production }, + only: { type: "string" }, + sku: { type: "string", multiple: true }, + device: { type: "string", multiple: true }, + requests: { type: "string" }, + json: { type: "string" }, + concurrency: { type: "string", default: "8" }, + examples: { type: "string", default: "3" }, + help: { type: "boolean", short: "h" }, + }, +}); + +if (args.help || !args.candidate) { + console.log(USAGE); + process.exit(args.help ? 0 : 2); +} + +const resolveTarget = (value: string) => (ALIASES[value] ?? value).replace(/\/+$/, ""); +const candidate = resolveTarget(args.candidate); +const reference = resolveTarget(args.reference!); + +const areas = new Set( + args.only + ? args.only.split(",").map(area => { + if (!AREAS.includes(area as Area)) + fail(`Unknown area "${area}". Areas: ${AREAS.join(", ")}`); + return area as Area; + }) + : AREAS.filter(area => area !== "replay"), +); +if (args.requests) areas.add("replay"); + +const skus = args.sku?.length ? args.sku : SKUS; +const concurrency = Number(args.concurrency); +const exampleCount = Number(args.examples); + +function fail(message: string): never { + console.error(message); + process.exit(2); +} + +// ---------------------------------------------------------------- HTTP + +async function send(base: string, probe: Probe): Promise { + for (let attempt = 1; ; attempt++) { + try { + const response = await fetch(base + probe.path, { + method: probe.method, + headers: probe.headers, + redirect: "manual", + signal: AbortSignal.timeout(20_000), + }); + const text = await response.text(); + if (response.status >= 500 && attempt < 2) + throw new Error(`HTTP ${response.status}`); + + const headers: Record = {}; + for (const name of COMPARED_HEADERS) { + const value = response.headers.get(name); + if (value !== null) headers[name] = value; + } + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + // not JSON + } + return { status: response.status, headers, body }; + } catch (error) { + if (attempt >= 2) + return { status: `error: ${(error as Error).message}`, headers: {}, body: null }; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } +} + +async function pool( + items: T[], + limit: number, + work: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const index = next++; + results[index] = await work(items[index]); + } + }), + ); + return results; +} + +// ---------------------------------------------------------------- requests + +/** One device ID per 5% rollout bucket, plus bucket 99, using the API's bucketing (md5 % 100). */ +function devicesAcrossBuckets(): string[] { + const bucket = (id: string) => + parseInt(createHash("md5").update(id).digest("hex").slice(0, 8), 16) % 100; + const wanted = new Set([...Array.from({ length: 20 }, (_, i) => i * 5), 99]); + const ids: string[] = []; + for (let n = 0; wanted.size > 0 && n < 100_000; n++) { + const id = `compare-device-${n}`; + if (wanted.delete(bucket(id))) ids.push(id); + } + return ids; +} + +const query = (params: Record) => + new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][], + ).toString(); + +/** + * Every registered version of one kind, newest first, as the reference API + * reports it: pinned requests ignore the rollout, so " { + const versions: string[] = []; + // ">=0.0.0" would normalize to "*", which the API treats as not pinned + let range = "<999999.0.0"; + for (let i = 0; i < 200; i++) { + const answer = await send(reference, { + area: "releases", + label: "discovery", + method: "GET", + path: `/releases?${query({ deviceId: "compare-discovery", sku, [`${kind}Version`]: range })}`, + }); + const version = (answer.body as Record | null)?.[`${kind}Version`]; + if (answer.status !== 200 || !version || versions.includes(version)) break; + versions.push(version); + range = `<${version}`; + } + return versions; +} + +async function releaseProbes(devices: string[]): Promise { + const probes: Probe[] = []; + const add = ( + label: string, + params: Record, + rolloutDependent = false, + ) => + probes.push({ + area: "releases", + label, + method: "GET", + path: `/releases?${query(params)}`, + rolloutDependent, + }); + + // One request at a time per chain, the chains side by side + const chains = skus.flatMap(sku => + (MINI_SKUS.has(sku) ? (["system"] as const) : (["app", "system"] as const)).map( + kind => ({ sku, kind }), + ), + ); + const discovered = await Promise.all( + chains.map(async chain => ({ + ...chain, + versions: await discoverVersions(chain.sku, chain.kind), + })), + ); + + // A chain stops at a version with no build for its SKU, so every SKU of a + // release type is probed with the versions any of its SKUs found. + const releaseType = (sku: string, kind: string) => (MINI_SKUS.has(sku) ? "mini" : kind); + const versionsByType = new Map>(); + for (const { sku, kind, versions } of discovered) { + const type = releaseType(sku, kind); + versionsByType.set(type, new Set([...(versionsByType.get(type) ?? []), ...versions])); + } + + for (const sku of skus) { + for (const deviceId of devices) add(`stable ${sku}`, { deviceId, sku }, true); + add(`prerelease ${sku}`, { deviceId: devices[0], sku, prerelease: "true" }); + + for (const { kind } of chains.filter(chain => chain.sku === sku)) { + for (const version of versionsByType.get(releaseType(sku, kind)) ?? []) { + add(`pinned ${kind} ${sku}`, { + deviceId: devices[0], + sku, + [`${kind}Version`]: version, + }); + add(`pinned ${kind} range ${sku}`, { + deviceId: devices[0], + sku, + [`${kind}Version`]: `<${version}`, + }); + } + } + } + + add("no SKU", { deviceId: devices[0] }, true); + add("invalid request", {}); + add("invalid request", { deviceId: "" }); + add("invalid request", { deviceId: devices[0], sku: "jetkvm-unknown" }); + add("invalid request", { + deviceId: devices[0], + appVersion: "9999.0.0", + systemVersion: "9999.0.0", + }); + return probes; +} + +function redirectProbes(): Probe[] { + const probes: Probe[] = []; + for (const path of ["/releases/app/latest", "/releases/system_recovery/latest"]) { + for (const sku of skus) { + for (const prerelease of [undefined, "true"]) { + probes.push({ + area: "redirects", + label: path, + method: "GET", + path: `${path}?${query({ sku, prerelease })}`, + }); + } + } + } + return probes; +} + +function authProbes(): Probe[] { + const routes: [string, string][] = [ + ["GET", "/me"], + ["GET", "/devices"], + ["GET", "/devices/compare-device"], + ["PUT", "/devices/compare-device"], + ["POST", "/webrtc/session"], + ["POST", "/webrtc/ice_config"], + ["POST", "/webrtc/turn_activity"], + ["POST", "/releases/sync"], + ]; + return routes.map(([method, path]) => ({ + area: "auth", + label: `${method} ${path}`, + method, + path, + })); +} + +function httpProbes(): Probe[] { + return [ + { area: "http", label: "root", method: "GET", path: "/" }, + { area: "http", label: "health", method: "GET", path: "/healthz" }, + { + area: "http", + label: "unknown route", + method: "GET", + path: "/compare-does-not-exist", + }, + { + area: "http", + label: "CORS from another origin", + method: "OPTIONS", + path: "/devices", + headers: { + origin: "https://compare.invalid", + "access-control-request-method": "GET", + }, + }, + ]; +} + +function replayProbes(file: string): Probe[] { + return readFileSync(file, "utf8") + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")) + .map(path => ({ + area: "replay" as const, + label: path.split("?")[0], + method: "GET", + path, + rolloutDependent: true, + })); +} + +// ---------------------------------------------------------------- comparison + +/** Values that differ on every call. */ +function normalize(body: unknown): unknown { + if (body && typeof body === "object" && !Array.isArray(body)) { + const { time: _time, ...rest } = body as Record; + return rest; + } + return body; +} + +function differences(ref: Answer, cand: Answer): Difference[] { + const found: Difference[] = []; + const walk = (field: string, a: unknown, b: unknown) => { + if ( + a && + b && + typeof a === "object" && + typeof b === "object" && + !Array.isArray(a) && + !Array.isArray(b) + ) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of [...keys].sort()) { + walk(field ? `${field}.${key}` : key, (a as any)[key], (b as any)[key]); + } + } else if (JSON.stringify(a) !== JSON.stringify(b)) { + found.push({ field, reference: a, candidate: b }); + } + }; + walk("status", ref.status, cand.status); + walk("header", ref.headers, cand.headers); + walk("body", normalize(ref.body), normalize(cand.body)); + return found; +} + +// ---------------------------------------------------------------- report + +const show = (value: unknown) => + value === undefined ? "(absent)" : JSON.stringify(value); + +async function main() { + console.log(`Reference ${reference}`); + console.log(`Candidate ${candidate}\n`); + + const healthProbe: Probe = { + area: "http", + label: "health", + method: "GET", + path: "/healthz", + }; + const health = await Promise.all( + [reference, candidate].map(base => send(base, healthProbe)), + ); + for (const [i, base] of [reference, candidate].entries()) { + if (health[i].status !== 200) + fail(`${base}/healthz answered ${health[i].status}; stopping.`); + } + + const devices = [...devicesAcrossBuckets(), ...(args.device ?? [])]; + const probes: Probe[] = []; + if (areas.has("releases")) probes.push(...(await releaseProbes(devices))); + if (areas.has("redirects")) probes.push(...redirectProbes()); + if (areas.has("auth")) probes.push(...authProbes()); + if (areas.has("http")) probes.push(...httpProbes()); + if (areas.has("replay") && args.requests) probes.push(...replayProbes(args.requests)); + + let done = 0; + const results = await pool(probes, concurrency, async probe => { + const [ref, cand] = await Promise.all([ + send(reference, probe), + send(candidate, probe), + ]); + if (++done % 25 === 0 || done === probes.length) { + process.stderr.write(`\r${done}/${probes.length} requests`); + } + return { + probe, + reference: ref, + candidate: cand, + differences: differences(ref, cand), + }; + }); + process.stderr.write("\n\n"); + + // Group by area, label and field, so one cause shows once however many requests it touches + const groups = new Map< + string, + { area: Area; label: string; field: string; members: typeof results } + >(); + for (const result of results) { + for (const { field } of result.differences) { + const { area, label } = result.probe; + const key = JSON.stringify([area, label, field]); + if (!groups.has(key)) groups.set(key, { area, label, field, members: [] }); + groups.get(key)!.members.push(result); + } + } + + // In a terminal, each group of differences must be acknowledged as expected by the person + // running the compare. Nothing is remembered between runs. + const prompt = process.stdin.isTTY + ? createInterface({ input: process.stdin, output: process.stdout }) + : undefined; + const sorted = [...groups.values()].sort((a, b) => b.members.length - a.members.length); + const acknowledged: { area: Area; label: string; field: string; expected: boolean }[] = + []; + for (const [index, { area, label, field, members }] of sorted.entries()) { + console.log( + `✗ [${index + 1}/${sorted.length}] ${area}: ${label}: ${field} (${members.length} request${members.length === 1 ? "" : "s"})`, + ); + for (const member of members.slice(0, exampleCount)) { + const difference = member.differences.find(d => d.field === field)!; + console.log(` ${member.probe.method} ${member.probe.path}`); + console.log(` reference ${show(difference.reference)}`); + console.log(` candidate ${show(difference.candidate)}`); + } + if (prompt) { + // End of input (Ctrl+D) counts as "no" + const answer = await prompt + .question(" Is this difference expected? [y/N] ") + .catch(() => ""); + acknowledged.push({ + area, + label, + field, + expected: /^y(es)?$/i.test(answer.trim()), + }); + } + console.log(); + } + prompt?.close(); + + const differing = results.filter(result => result.differences.length > 0); + for (const area of areas) { + const inArea = results.filter(result => result.probe.area === area); + if (inArea.length === 0) continue; + const bad = inArea.filter(result => result.differences.length > 0).length; + console.log( + `${bad === 0 ? "✓" : "✗"} ${area.padEnd(10)} ${inArea.length - bad}/${inArea.length} identical`, + ); + } + + if (differing.length > 0 && differing.every(result => result.probe.rolloutDependent)) { + console.log( + "\nEvery difference is in a request that depends on the rollout table, and every pinned request matches.\n" + + "The rollout percentages in the two databases probably differ.", + ); + } + + if (args.json) { + writeFileSync( + args.json, + JSON.stringify({ reference, candidate, acknowledged, results }, null, 2), + ); + console.log(`\nFull results: ${args.json}`); + } + + if (differing.length === 0) { + console.log("\nNo differences"); + process.exit(0); + } + console.log(`\n${differing.length} of ${results.length} requests differ`); + if (!prompt) { + console.log("Run in a terminal to review each group of differences."); + process.exit(1); + } + const unexpected = acknowledged.filter(group => !group.expected).length; + console.log( + unexpected === 0 + ? `You marked all ${sorted.length} groups of differences as expected` + : `${unexpected} of ${sorted.length} groups of differences are not marked as expected`, + ); + process.exit(unexpected === 0 ? 0 : 1); +} + +await main(); diff --git a/scripts/compare-releases.sh b/scripts/compare-releases.sh deleted file mode 100755 index 841bfaa..0000000 --- a/scripts/compare-releases.sh +++ /dev/null @@ -1,875 +0,0 @@ -#!/usr/bin/env bash - -set -uo pipefail - -LOCAL_BASE="${LOCAL_BASE:-http://localhost:3000}" -PROD_BASE="${PROD_BASE:-https://api.jetkvm.com}" - -# Two device IDs picked so they straddle a typical staged rollout window: -# compare-device-1 → rollout bucket 81 (skips releases < 81%) -# compare-device-2 → rollout bucket 9 (catches releases >= 10%) -# Together they exercise both eligible and ineligible rollout paths. -DEFAULT_DEVICE_IDS=("compare-device-1" "compare-device-2") -DEFAULT_SKUS=("__omit__" "jetkvm-v2" "jetkvm-v2-sdmmc") -# SKUs whose responses are allowed to differ from production until the SKU -# table is deployed there: the two Mini variants (no app artifact, no release -# yet) and one unregistered SKU (400 locally, 404 on the old code). -EXTRA_SKUS="${EXTRA_SKUS:-jetkvm-mini-ethernet jetkvm-mini-wireless jetkvm-v3}" -TRISTATE_VALUES=("__omit__" "false" "true") - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -PASS_COUNT=0 -FAIL_COUNT=0 -ACCEPTED_COUNT=0 -CASE_COUNT=0 -CASE_INDEX=0 -TOTAL_CASES=0 -PROGRESS_WIDTH=40 - -print_usage() { - cat <<'EOF' -Usage: scripts/compare-releases.sh [device_id ...] - -Compares release endpoint responses between: - - local API - - api.jetkvm.com - -Defaults: - LOCAL_BASE=http://localhost:3000 - PROD_BASE=https://api.jetkvm.com - device_ids=(compare-device-1) - -Environment overrides: - LOCAL_BASE Override local host - PROD_BASE Override production host - CURL_TIMEOUT Curl max time in seconds (default: 30) - CURL_CONNECT_TIMEOUT Curl connect timeout in seconds (default: 10) - FAIL_FAST Stop after first failed case (default: true) - EXTRA_SKUS Space-separated SKUs compared with the expected 400-vs-404 - deviation accepted (default: both Mini SKUs and jetkvm-v3) - -Examples: - scripts/compare-releases.sh - scripts/compare-releases.sh device-a device-b - LOCAL_BASE=http://localhost:3001 PROD_BASE=https://api.jetkvm.com scripts/compare-releases.sh -EOF -} - -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - print_usage - exit 0 -fi - -if (($# > 0)); then - DEVICE_IDS=("$@") -else - DEVICE_IDS=("${DEFAULT_DEVICE_IDS[@]}") -fi - -CURL_TIMEOUT="${CURL_TIMEOUT:-30}" -CURL_CONNECT_TIMEOUT="${CURL_CONNECT_TIMEOUT:-10}" -MAX_PARALLEL="${MAX_PARALLEL:-5}" -RETRY_COUNT="${RETRY_COUNT:-2}" -RETRY_DELAY_SECONDS="${RETRY_DELAY_SECONDS:-1}" -FAIL_FAST="${FAIL_FAST:-true}" - -log() { - printf '%s\n' "$*" -} - -render_progress() { - local completed="$1" - local total="$2" - local width="${3:-$PROGRESS_WIDTH}" - local filled=0 - local empty=0 - - if (( total > 0 )); then - filled=$(( completed * width / total )) - fi - empty=$(( width - filled )) - - printf '%*s' "$filled" '' | tr ' ' '#' - printf '%*s' "$empty" '' -} - -urlencode() { - python3 - "$1" <<'PY' -import sys -from urllib.parse import quote - -print(quote(sys.argv[1], safe="")) -PY -} - -join_query() { - local -n query_keys_ref=$1 - local -n query_values_ref=$2 - local query="" - local i key value encoded - - for i in "${!query_keys_ref[@]}"; do - key="${query_keys_ref[$i]}" - value="${query_values_ref[$i]}" - [[ "$value" == "__omit__" ]] && continue - encoded="$(urlencode "$value")" - if [[ -n "$query" ]]; then - query+="&" - fi - query+="${key}=${encoded}" - done - - printf '%s' "$query" -} - -header_value() { - local file="$1" - local name="$2" - python3 - "$file" "$name" <<'PY' -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -name = sys.argv[2].lower() -value = "" -for raw_line in path.read_text(errors="replace").splitlines(): - line = raw_line.strip() - if not line or ":" not in line: - continue - key, candidate = line.split(":", 1) - if key.lower() == name: - value = candidate.strip() -print(value) -PY -} - -normalize_body() { - local body_file="$1" - local normalized_file="$2" - if [[ ! -f "$body_file" ]]; then - : >"$normalized_file" - return - fi - python3 - "$body_file" "$normalized_file" <<'PY' -import json -import sys -from pathlib import Path - -body_path = Path(sys.argv[1]) -normalized_path = Path(sys.argv[2]) -body = body_path.read_text(errors="replace") - -try: - parsed = json.loads(body) -except Exception: - normalized_path.write_text(body) -else: - # Resolver bookkeeping that older deploys still serialize; the device - # never reads it, so it is not part of the comparison. - ignored_suffixes = ("CachedAt", "MaxSatisfying") - - def scrub(value): - if isinstance(value, dict): - return { - key: scrub(child) - for key, child in value.items() - if not key.endswith(ignored_suffixes) - } - if isinstance(value, list): - return [scrub(item) for item in value] - return value - - normalized_path.write_text(json.dumps(scrub(parsed), indent=2, sort_keys=True) + "\n") -PY -} - -summarize_body_mismatch() { - local left_file="$1" - local right_file="$2" - python3 - "$left_file" "$right_file" <<'PY' -import json -import sys -from pathlib import Path - -left_path = Path(sys.argv[1]) -right_path = Path(sys.argv[2]) - -def load(path): - try: - return json.loads(path.read_text(errors="replace")) - except Exception: - return path.read_text(errors="replace") - -left = load(left_path) -right = load(right_path) - -def walk(a, b, path="$"): - if type(a) != type(b): - return path, a, b - if isinstance(a, dict): - keys = sorted(set(a) | set(b)) - for key in keys: - if key not in a: - return f"{path}.{key}", "", b[key] - if key not in b: - return f"{path}.{key}", a[key], "" - result = walk(a[key], b[key], f"{path}.{key}") - if result is not None: - return result - return None - if isinstance(a, list): - if len(a) != len(b): - return f"{path}.length", len(a), len(b) - for idx, (av, bv) in enumerate(zip(a, b)): - result = walk(av, bv, f"{path}[{idx}]") - if result is not None: - return result - return None - if a != b: - return path, a, b - return None - -result = walk(left, right) -if result is None: - print("values differ") -else: - path, left_value, right_value = result - print(f"path={path}") - print(f"local={json.dumps(left_value, sort_keys=True)}") - print(f"prod={json.dumps(right_value, sort_keys=True)}") -PY -} - -body_diff_is_version_only_not_found() { - local left_file="$1" - local right_file="$2" - python3 - "$left_file" "$right_file" <<'PY' -import json -import re -import sys -from pathlib import Path - -try: - left = json.loads(Path(sys.argv[1]).read_text(errors="replace")) - right = json.loads(Path(sys.argv[2]).read_text(errors="replace")) -except Exception: - raise SystemExit(1) - -if not (isinstance(left, dict) and isinstance(right, dict)): - raise SystemExit(1) - -if left.get("name") != "NotFoundError" or right.get("name") != "NotFoundError": - raise SystemExit(1) - -left_keys = set(left.keys()) -right_keys = set(right.keys()) -if left_keys != {"name", "message"} or right_keys != {"name", "message"}: - raise SystemExit(1) - -# Both messages mean "no release compatible with this SKU is available"; -# the wording differs across deploys but the device sees the same 404. -no_compat_patterns = [ - re.compile(r'^Version .+ has no artifact for SKU "([^"]+)"$'), - re.compile(r'^Version .+ predates SKU support and cannot serve SKU "([^"]+)"$'), - re.compile(r'^SKU "([^"]+)" is not available for version .+$'), - re.compile(r'^No default (?:app|system|mini) release available for SKU "([^"]+)"$'), - re.compile(r'^No release found for type \S+ and SKU "([^"]+)"$'), -] - -def canonicalize(message): - for pattern in no_compat_patterns: - match = pattern.match(message) - if match: - return f'' - return message - -left_message = left.get("message", "") -right_message = right.get("message", "") - -if canonicalize(left_message) == canonicalize(right_message): - raise SystemExit(0) - -raise SystemExit(1) -PY -} - -is_accepted_deviation() { - local query="$1" - local left_prefix="$2" - local right_prefix="$3" - EXTRA_SKUS="$EXTRA_SKUS" python3 - "$query" "${left_prefix}.meta" "${right_prefix}.meta" "${left_prefix}.normalized" "${right_prefix}.normalized" <<'PY' -import json -import os -import sys -from pathlib import Path -from urllib.parse import parse_qs - -query, left_meta_path, right_meta_path, left_body_path, right_body_path = sys.argv[1:] -params = parse_qs(query, keep_blank_values=True) - -def one(name): - values = params.get(name, []) - return values[0] if values else None - -def parse_meta(path): - data = {} - for line in Path(path).read_text(errors="replace").splitlines(): - if "=" in line: - key, value = line.split("=", 1) - data[key] = value - return data - -def load_json(path): - try: - return json.loads(Path(path).read_text(errors="replace")) - except Exception: - return None - -left_meta = parse_meta(left_meta_path) -right_meta = parse_meta(right_meta_path) -left_body = load_json(left_body_path) -right_body = load_json(right_body_path) - -def accept(reason): - print(reason) - raise SystemExit(0) - -# Accepted behavior change: -# For SKUs in EXTRA_SKUS (unregistered, or registered but without releases or -# without the requested artifact) the SKU table answers 400 or 404 with its -# own wording; the code before it answered 400 or 404 from a different check. -# Both sides refusing with a client error means the same thing here. -if one("sku") in os.environ.get("EXTRA_SKUS", "").split(): - def refuses(meta, body): - return ( - meta.get("http_code") in ("400", "404") - and isinstance(body, dict) - and str(body.get("name", "")).endswith("Error") - ) - if refuses(left_meta, left_body) and refuses(right_meta, right_body): - accept("SKU table: both sides refuse this SKU with a 4xx (wording and status may differ)") - -# Accepted behavior change: -# Stable requests with prerelease/dev version constraints are DB-only locally. -# Production still resolves those directly from S3. Local 404 vs prod 200 is expected. -if one("prerelease") not in (None, "false"): - raise SystemExit(1) - -constrained_versions = [one("appVersion"), one("systemVersion")] -has_dev_constraint = any(value and "-" in value for value in constrained_versions) -if not has_dev_constraint: - raise SystemExit(1) - -if left_meta.get("http_code") != "404" or right_meta.get("http_code") != "200": - raise SystemExit(1) - -if not isinstance(left_body, dict) or left_body.get("name") != "NotFoundError": - raise SystemExit(1) - -if not isinstance(right_body, dict) or not right_body.get("appVersion") or not right_body.get("systemVersion"): - raise SystemExit(1) - -accept("stable dev/prerelease version constraints are DB-only locally") -PY -} - -curl_capture() { - local base_url="$1" - local path="$2" - local query="$3" - local prefix="$4" - local url="${base_url}${path}" - local headers_file="${prefix}.headers" - local body_file="${prefix}.body" - local meta_file="${prefix}.meta" - local stderr_file="${prefix}.stderr" - local exit_file="${prefix}.exit" - local attempt=0 - local curl_exit=0 - local http_code="" - - if [[ -n "$query" ]]; then - url="${url}?${query}" - fi - - while :; do - : >"$headers_file" - : >"$body_file" - : >"$meta_file" - : >"$stderr_file" - - curl_exit=0 - curl \ - --silent \ - --show-error \ - --connect-timeout "$CURL_CONNECT_TIMEOUT" \ - --max-time "$CURL_TIMEOUT" \ - --dump-header "$headers_file" \ - --output "$body_file" \ - --write-out "http_code=%{http_code}\ncontent_type=%{content_type}\n" \ - "$url" >"$meta_file" 2>"$stderr_file" || curl_exit=$? - - printf '%s\n' "$curl_exit" >"$exit_file" - http_code="$(sed -n 's/^http_code=//p' "$meta_file")" - - if (( curl_exit == 0 )) && [[ ! "$http_code" =~ ^52[0-9]$ ]]; then - break - fi - - if (( attempt >= RETRY_COUNT )); then - break - fi - - attempt=$((attempt + 1)) - sleep "$RETRY_DELAY_SECONDS" - done -} - -compare_scalar_files() { - local label="$1" - local left_file="$2" - local right_file="$3" - local left_value right_value - - left_value="$(tr -d '\r' <"$left_file")" - right_value="$(tr -d '\r' <"$right_file")" - if [[ "$left_value" == "$right_value" ]]; then - return 1 - fi - - printf '%s\n' "$label" - printf ' local=%s\n' "${left_value:-}" - printf ' prod=%s\n' "${right_value:-}" - return 0 -} - -summarize_meta_mismatch() { - local left_file="$1" - local right_file="$2" - python3 - "$left_file" "$right_file" <<'PY' -import sys -from pathlib import Path - -def parse(path_str): - data = {} - for line in Path(path_str).read_text(errors="replace").splitlines(): - if "=" not in line: - continue - key, value = line.split("=", 1) - data[key] = value - return data - -left = parse(sys.argv[1]) -right = parse(sys.argv[2]) -keys = sorted(set(left) | set(right)) -for key in keys: - if left.get(key) != right.get(key): - print(f"{key}") - print(f"local={left.get(key, '')}") - print(f"prod={right.get(key, '')}") -PY -} - -write_case_result() { - local result_file="$1" - local case_name="$2" - local path="$3" - local query="$4" - local left_prefix="$5" - local right_prefix="$6" - local left_norm="${left_prefix}.normalized" - local right_norm="${right_prefix}.normalized" - local left_location right_location - local failed=0 - local details="" - local mismatch_count=0 - local output="" - local accepted_reason="" - - normalize_body "${left_prefix}.body" "$left_norm" - normalize_body "${right_prefix}.body" "$right_norm" - - if output="$(compare_scalar_files "exit-code mismatch" "${left_prefix}.exit" "${right_prefix}.exit")"; then - mismatch_count=$((mismatch_count + 1)) - details+="$output"$'\n' - failed=1 - fi - - if output="$(summarize_meta_mismatch "${left_prefix}.meta" "${right_prefix}.meta")" && [[ -n "$output" ]]; then - mismatch_count=$((mismatch_count + 1)) - details+=$' response-meta mismatch\n' - details+="$(printf '%s\n' "$output" | sed 's/^/ /')"$'\n' - failed=1 - fi - - left_location="$(header_value "${left_prefix}.headers" "location")" - right_location="$(header_value "${right_prefix}.headers" "location")" - if [[ "$left_location" != "$right_location" ]]; then - mismatch_count=$((mismatch_count + 1)) - details+=$' location mismatch\n' - details+=" local=${left_location:-}"$'\n' - details+=" prod=${right_location:-}"$'\n' - failed=1 - fi - - if [[ -s "${left_prefix}.body" || -s "${right_prefix}.body" ]]; then - if ! cmp -s "$left_norm" "$right_norm"; then - if body_diff_is_version_only_not_found "$left_norm" "$right_norm"; then - : - else - mismatch_count=$((mismatch_count + 1)) - details+=$' body mismatch\n' - details+="$(summarize_body_mismatch "$left_norm" "$right_norm" | sed 's/^/ /')"$'\n' - failed=1 - fi - fi - fi - - if (( failed == 1 )) && accepted_reason="$(is_accepted_deviation "$query" "$left_prefix" "$right_prefix")"; then - failed=0 - details="" - mismatch_count=0 - else - accepted_reason="" - fi - - { - printf 'status=%s\n' "$([[ $failed -eq 0 ]] && { [[ -n "$accepted_reason" ]] && printf accepted || printf pass; } || printf fail)" - printf 'case_name=%s\n' "$case_name" - printf 'path=%s\n' "$path" - printf 'query=%s\n' "$query" - printf 'accepted_reason=%s\n' "$accepted_reason" - printf 'mismatch_count=%s\n' "$mismatch_count" - printf 'details<<__DETAILS__\n%s__DETAILS__\n' "$details" - printf 'local_stderr<<__STDERR__\n%s__STDERR__\n' "$(tr '\n' ' ' <"${left_prefix}.stderr")" - printf 'prod_stderr<<__STDERR__\n%s__STDERR__\n' "$(tr '\n' ' ' <"${right_prefix}.stderr")" - } >"$result_file" -} - -run_case_worker() { - local case_name="$1" - local path="$2" - local query="$3" - local safe_case="$4" - local result_file="$5" - - local local_prefix="$TMP_DIR/${safe_case}.local" - local prod_prefix="$TMP_DIR/${safe_case}.prod" - - curl_capture "$LOCAL_BASE" "$path" "$query" "$local_prefix" & - local local_pid=$! - curl_capture "$PROD_BASE" "$path" "$query" "$prod_prefix" & - local prod_pid=$! - wait "$local_pid" - wait "$prod_pid" - - write_case_result "$result_file" "$case_name" "$path" "$query" "$local_prefix" "$prod_prefix" -} - -print_case_result() { - local result_file="$1" - local progress_bar - local status case_name path query accepted_reason mismatch_count details local_stderr prod_stderr - - progress_bar="$(render_progress "$CASE_INDEX" "$TOTAL_CASES")" - status="$(sed -n 's/^status=//p' "$result_file")" - case_name="$(sed -n 's/^case_name=//p' "$result_file")" - path="$(sed -n 's/^path=//p' "$result_file")" - query="$(sed -n 's/^query=//p' "$result_file")" - accepted_reason="$(sed -n 's/^accepted_reason=//p' "$result_file")" - mismatch_count="$(sed -n 's/^mismatch_count=//p' "$result_file")" - details="$(awk '/^details<<__DETAILS__/{flag=1;next}/^__DETAILS__$/{flag=0}flag' "$result_file")" - local_stderr="$(awk '/^local_stderr<<__STDERR__/{flag=1;next}/^__STDERR__$/{if(flag){flag=0; exit}}flag' "$result_file")" - prod_stderr="$(awk 'found && /^__STDERR__$/ {exit} /^prod_stderr<<__STDERR__$/ {found=1; next} found {print}' "$result_file")" - - if [[ "$status" == "pass" ]]; then - PASS_COUNT=$((PASS_COUNT + 1)) - printf '\r[%s] %4d/%-4d | pass:%d fail:%d' \ - "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$FAIL_COUNT" - if (( CASE_INDEX == TOTAL_CASES )); then - printf '\n' - fi - elif [[ "$status" == "accepted" ]]; then - ACCEPTED_COUNT=$((ACCEPTED_COUNT + 1)) - printf '\r\033[K' - printf '[%s] %4d/%-4d | pass:%d accepted:%d fail:%d\n' \ - "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$ACCEPTED_COUNT" "$FAIL_COUNT" - printf ' ACCEPT %s\n' "$case_name" - printf ' %s%s%s\n' "$path" "${query:+?$query}" "" - printf ' %s\n' "$accepted_reason" - else - FAIL_COUNT=$((FAIL_COUNT + 1)) - printf '\r\033[K' - printf '[%s] %4d/%-4d | pass:%d fail:%d\n' \ - "$progress_bar" "$CASE_INDEX" "$TOTAL_CASES" "$PASS_COUNT" "$FAIL_COUNT" - printf ' FAIL %s\n' "$case_name" - printf ' %s%s%s\n' "$path" "${query:+?$query}" "" - printf '%s' "$details" - if [[ -n "$local_stderr" || -n "$prod_stderr" ]]; then - printf ' stderr\n' - printf ' local=%s\n' "$local_stderr" - printf ' prod=%s\n' "$prod_stderr" - fi - if [[ "${mismatch_count:-0}" == "0" ]]; then - printf ' mismatch detected\n' - fi - fi -} - -stop_requested() { - [[ "$FAIL_FAST" != "false" && "$FAIL_COUNT" -gt 0 ]] -} - -wait_for_one_job() { - local pid done_pid result_file - while :; do - for pid in "${!JOB_RESULT_FILES[@]}"; do - if ! kill -0 "$pid" 2>/dev/null; then - wait "$pid" || true - done_pid="$pid" - result_file="${JOB_RESULT_FILES[$pid]}" - unset "JOB_RESULT_FILES[$pid]" - CASE_INDEX=$((CASE_INDEX + 1)) - print_case_result "$result_file" - rm -f "$result_file" - return - fi - done - sleep 0.05 - done -} - -drain_jobs() { - while ((${#JOB_RESULT_FILES[@]} > 0)); do - wait_for_one_job - if stop_requested; then - for pid in "${!JOB_RESULT_FILES[@]}"; do - kill "$pid" 2>/dev/null || true - done - JOB_RESULT_FILES=() - break - fi - done -} - -extract_versions() { - local device_id="$1" - local prerelease="$2" - local sku="$3" - local prefix="$4" - - local query_keys=("deviceId" "prerelease" "sku") - local query_values=("$device_id" "$prerelease" "$sku") - local query - query="$(join_query query_keys query_values)" - - curl_capture "$PROD_BASE" "/releases" "$query" "$prefix" - - python3 - "$prefix.body" <<'PY' -import json -import sys -from pathlib import Path - -path = Path(sys.argv[1]) -try: - payload = json.loads(path.read_text(errors="replace")) -except Exception: - print("") - print("") - raise SystemExit(0) - -print(payload.get("appVersion", "")) -print(payload.get("systemVersion", "")) -PY -} - -build_value_set() { - local exact_version="$1" - local prerelease_version="$2" - local values=("__omit__" "*") - - if [[ -n "$exact_version" ]]; then - values+=("$exact_version") - fi - if [[ -n "$prerelease_version" && "$prerelease_version" != "$exact_version" ]]; then - values+=("$prerelease_version") - fi - - printf '%s\n' "${values[@]}" | awk '!seen[$0]++' -} - -run_case() { - local case_name="$1" - local path="$2" - local -n case_keys_ref=$3 - local -n case_values_ref=$4 - local query - - CASE_COUNT=$((CASE_COUNT + 1)) - query="$(join_query case_keys_ref case_values_ref)" - - local safe_case - safe_case="$(printf '%s' "$case_name" | tr ' /?=&' '_____')" - local result_file="$TMP_DIR/${safe_case}.result" - - run_case_worker "$case_name" "$path" "$query" "$safe_case" "$result_file" & - JOB_RESULT_FILES[$!]="$result_file" - - while ((${#JOB_RESULT_FILES[@]} >= MAX_PARALLEL)); do - wait_for_one_job - if stop_requested; then - return - fi - done -} - -log "Comparing release endpoints" -log " local: $LOCAL_BASE" -log " prod: $PROD_BASE" -log " deviceIds: ${DEVICE_IDS[*]}" - -mapfile -t stable_versions < <(extract_versions "${DEVICE_IDS[0]}" "__omit__" "__omit__" "$TMP_DIR/baseline-stable") -mapfile -t prerelease_versions < <(extract_versions "${DEVICE_IDS[0]}" "true" "__omit__" "$TMP_DIR/baseline-prerelease") - -STABLE_APP_VERSION="${stable_versions[0]:-}" -STABLE_SYSTEM_VERSION="${stable_versions[1]:-}" -PRERELEASE_APP_VERSION="${prerelease_versions[0]:-}" -PRERELEASE_SYSTEM_VERSION="${prerelease_versions[1]:-}" - -# Range constraints around the live stable version: the caret range resolves -# to the newest patch of the current minor, " 0)); then - exit 1 -fi diff --git a/src/auth.ts b/src/auth.ts index 96c895d..6ddb9e9 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -38,7 +38,8 @@ export const verifyToken = async (idToken: string) => { } }; -export const authenticated = async (req: Request, res: Response, next: NextFunction) => { +/** The verified ID token of the session's user; throws UnauthorizedError otherwise. */ +export const verifySession = async (req: Request) => { const idToken = req.session?.id_token; if (!idToken) throw new UnauthorizedError(); @@ -55,6 +56,11 @@ export const authenticated = async (req: Request, res: Response, next: NextFunct throw new UnauthorizedError("Account is not in the allowlist", "account_not_allowed"); } + return payload; +}; + +export const authenticated = async (req: Request, res: Response, next: NextFunction) => { + await verifySession(req); next(); }; diff --git a/src/devices.ts b/src/devices.ts index b6d54d4..b30ebd4 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -8,7 +8,7 @@ import { UnprocessableEntityError, } from "./errors"; import * as crypto from "crypto"; -import { authenticated } from "./auth"; +import { verifySession } from "./auth"; import { activeConnections } from "./webrtc-signaling"; import { effectiveSku } from "./skus"; @@ -124,19 +124,8 @@ export const Delete = async ( return res.status(204).send(); } - // If the user doesn't have a secret token, we check their session cookie - try { - await new Promise(resolve => { - authenticated(req, res, () => { - resolve(); - }); - }); - } catch (error) { - throw new BadRequestError("Unauthorized"); - } - - const idToken = req.session?.id_token; - const { sub } = jose.decodeJwt(idToken); + // Without a device token, only a signed-in owner may delete the device + const { sub } = await verifySession(req); if (!sub) throw new UnauthorizedError("Missing sub in token"); const { id } = req.params; diff --git a/src/release-sync.ts b/src/release-sync.ts index b176040..8fdd32d 100644 --- a/src/release-sync.ts +++ b/src/release-sync.ts @@ -36,7 +36,8 @@ export interface ReleaseArtifactInput { compatibleSkus: string[]; } -export const DEFAULT_ROLLOUT_PERCENTAGE = 10; +/** A new release is registered but served to no device until an operator raises it. */ +export const DEFAULT_ROLLOUT_PERCENTAGE = 0; export type ReleaseOutcome = | "created" diff --git a/src/releases.ts b/src/releases.ts index 30310fd..5ff4ccd 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import type { Prisma } from "@prisma/client"; import { prisma } from "./db"; import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "./errors"; import type { ReleaseSyncRunner } from "./release-sync"; @@ -146,7 +147,7 @@ const objectExists = (key: string) => s3ObjectExists(s3Client, bucketName, key); * - Fails if the requested SKU is not available * * For legacy versions (no skus/ folder): - * - Returns legacy path for SKUs that predate the SKU layout + * - Returns legacy path for SKUs that predate the SKU layout, if the file exists * - Fails for every other SKU because legacy firmware predates * that hardware and may not be compatible * @@ -176,8 +177,9 @@ async function resolveArtifactPath( // For legacy versions (pre-SKU folder structure), we only serve the SKUs // that predate the layout. This prevents newer hardware variants from // rolling back to old firmware that may not have compatible binaries. - if (legacyCompatibleSkus(prefix).includes(sku)) { - return `${prefix}/${version}/${file}`; + const legacyPath = `${prefix}/${version}/${file}`; + if (legacyCompatibleSkus(prefix).includes(sku) && (await objectExists(legacyPath))) { + return legacyPath; } throw noArtifactForSku(version, sku); @@ -237,66 +239,77 @@ async function enrichWithSigUrls(release: Release, sku: string): Promise { ); } +/** Versions checked, newest first, before newestVersionWithArtifact gives up. */ +const MAX_FALLBACK_VERSIONS = 5; + +/** + * The newest version under the artifact's prefix that satisfies the range and + * has the artifact for the SKU, and that artifact's path. A version without a + * build for the SKU is skipped, as the stable path skips releases without a + * compatible artifact, so a SKU missing from one upload does not hide older ones. + * Only the newest `maxVersions` matching versions are checked. + */ +async function newestVersionWithArtifact( + { prefix, file }: Artifact, + sku: string, + range: string, + includePrerelease: boolean, + maxVersions = MAX_FALLBACK_VERSIONS, +): Promise<{ version: string; path: string }> { + const response = await s3Client.send( + new ListObjectsV2Command({ Bucket: bucketName, Prefix: `${prefix}/`, Delimiter: "/" }), + ); + + const versions = (response.CommonPrefixes ?? []) + .map(cp => cp.Prefix!.split("/")[1]) + .filter(v => v && semver.valid(v)); + if (versions.length === 0) { + throw new NotFoundError(`No versions found under prefix ${prefix}`); + } + + const candidates = semver.rsort( + versions.filter(v => semver.satisfies(v, range, { includePrerelease })), + ); + if (candidates.length === 0) { + throw new NotFoundError(`No version found under prefix ${prefix} that satisfies ${range}`); + } + + // Each version costs one or two S3 calls and a miss is not cached, so the + // walk is short: a version without the SKU's build is an upload mistake, + // not something that runs for many versions. + for (const version of candidates.slice(0, maxVersions)) { + try { + return { version, path: await resolveArtifactPath(prefix, version, sku, file) }; + } catch (error) { + if (!(error instanceof NotFoundError)) throw error; + } + } + throw noArtifactForSku(candidates[0], sku); +} + async function getLatestVersion( artifact: Artifact, includePrerelease: boolean, maxSatisfying: string = "*", sku: string, ): Promise { - const { prefix } = artifact; - const cacheKey = `${prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; + const cacheKey = `${artifact.prefix}-${includePrerelease}-${maxSatisfying}-${sku}`; const cached = releaseCache.get(cacheKey); if (cached) return cached; - const listCommand = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: prefix + "/", - Delimiter: "/", - }); - - const response = await s3Client.send(listCommand); - - if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError(`No versions found under prefix ${prefix}`); - } - - // Extract version folder names - let versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]) - .filter(Boolean) - .filter(v => semver.valid(v)); - - if (versions.length === 0) { - throw new NotFoundError(`No valid versions found under prefix ${prefix}`); - } - - // Get the latest version, optionally including prerelease versions - const latestVersion = semver.maxSatisfying(versions, maxSatisfying, { + const { version, path } = await newestVersionWithArtifact( + artifact, + sku, + maxSatisfying, includePrerelease, - }) as string; - if (!latestVersion) { - throw new NotFoundError( - `No version found under prefix ${prefix} that satisfies ${maxSatisfying}`, - ); - } - - const selectedPath = await resolveArtifactPath(prefix, latestVersion, sku, artifact.file); - const url = `${baseUrl}/${selectedPath}`; + ); const hashResponse = await s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: `${selectedPath}.sha256`, - }), + new GetObjectCommand({ Bucket: bucketName, Key: `${path}.sha256` }), ); - const hash = await streamToString(hashResponse.Body); - // Cache the release metadata - const release: ReleaseMetadata = { - version: latestVersion, - url, - hash, - }; + const release: ReleaseMetadata = { version, url: `${baseUrl}/${path}`, hash }; releaseCache.set(cacheKey, release); return release; } @@ -428,8 +441,10 @@ function dbReleaseToMetadata(release: DbRelease, sku: string): ReleaseMetadata { } /** - * Newest fully rolled out release for the prefix, or null when no release - * has reached 100% yet (a prefix whose first release is still staged). + * Newest fully rolled out release that ships a binary for the SKU, or null + * when there is none: the prefix's first release is still staged, or the SKU + * is new and its first build has not reached 100% yet. The caller decides + * whether the device is in the staged release's bucket before it needs this. */ async function getDefaultRelease(prefix: string, sku: string): Promise { const rolledOutReleases = await prisma.release.findMany({ @@ -447,9 +462,7 @@ async function getDefaultRelease(prefix: string, sku: string): Promise r.artifacts.length > 0); if (compatibleReleases.length === 0) { - throw new NotFoundError( - `No default ${prefix} release available for SKU "${sku}"`, - ); + return null; } const latestVersion = semver.maxSatisfying( @@ -468,17 +481,22 @@ async function getDefaultRelease(prefix: string, sku: string): Promise { - return getReleaseByRange(prefix, sku, "*"); + return getReleaseByRange(prefix, sku, "*", { rolloutPercentage: { gt: 0 } }); } async function getReleaseByRange( prefix: string, sku: string, range: string, + where: Prisma.ReleaseWhereInput = {}, ): Promise { const releases = await prisma.release.findMany({ - where: { type: prefix }, + where: { type: prefix, ...where }, select: compatibleReleaseSelect(sku), }); @@ -503,6 +521,34 @@ async function getReleaseByRange( return latestRelease; } +/** + * The release a background update check offers: the newest release that is + * rolling out, if the device is in its bucket and it has a build for the SKU, + * else the default (the newest 100% release with a build for the SKU). A + * staged release without a build for the SKU (e.g. a SKU-specific build has + * not shipped yet) keeps the default rather than 404 the whole request. + */ +async function rolledOutRelease( + prefix: string, + sku: string, + deviceId: string, +): Promise { + // Sequential on purpose: when the prefix has no releases at all, the 404 + // from the latest lookup must win over the 500 from the default lookup. + const latest = await getLatestRelease(prefix, sku); + const fallback = await getDefaultRelease(prefix, sku); + + const useLatest = + latest.artifacts.length > 0 && + (await isDeviceEligibleForLatestRelease(latest.rolloutPercentage, deviceId)); + + const chosen = useLatest ? latest : fallback; + if (!chosen) { + throw new NotFoundError(`No ${prefix} release is rolled out yet for SKU "${sku}"`); + } + return dbReleaseToMetadata(chosen, sku); +} + export async function Retrieve(req: Request, res: Response) { const query = parseQuery(retrieveQuerySchema, req); const { sku, deviceId } = query; @@ -512,7 +558,6 @@ export async function Retrieve(req: Request, res: Response) { app: toSemverRange(query.appVersion), system: toSemverRange(query.systemVersion), }; - const skipRollout = artifacts.some(({ kind }) => constraints[kind] !== "*"); // Prereleases are not imported into the DB by the stable sync script. if (query.prerelease) { @@ -531,45 +576,17 @@ export async function Retrieve(req: Request, res: Response) { return res.json(remoteRelease); } - // Version-constrained stable requests skip rollout but still read DB metadata. - if (skipRollout) { - const constrained: OtaReleases = {}; - for (const { kind, prefix } of artifacts) { - constrained[kind] = dbReleaseToMetadata( - await getReleaseByRange(prefix, sku, constraints[kind]), - sku, - ); - } - const responseJson = toRelease(constrained); - await addStableSigUrls(responseJson); - return res.json(responseJson); - } - - // Background update checks follow rollout percentages so new releases roll - // out gradually. Devices outside the bucket fall back to the default (the - // newest 100%-rolled-out release). If the latest release lacks a compatible - // artifact for this SKU (e.g. a SKU-specific build hasn't shipped yet) we - // silently keep the default rather than 404 the whole request. + // A pinned part (appVersion, systemVersion) gets the newest release in its + // range, rolled out or not. Every other part follows the rollout, as a + // background update check does, so pinning one part never offers an + // unreleased build of the other. const offered: OtaReleases = {}; await Promise.all( artifacts.map(async ({ kind, prefix }) => { - // Sequential on purpose: when the prefix has no releases at all, the - // 404 from the latest lookup must win over the 500 from the default - // lookup, which the parallel form left to query timing. - const latest = await getLatestRelease(prefix, sku); - const fallback = await getDefaultRelease(prefix, sku); - - const useLatest = - latest.artifacts.length > 0 && - (await isDeviceEligibleForLatestRelease(latest.rolloutPercentage, deviceId)); - - const chosen = useLatest ? latest : fallback; - if (!chosen) { - throw new NotFoundError( - `No ${prefix} release is rolled out yet for SKU "${sku}"`, - ); - } - offered[kind] = dbReleaseToMetadata(chosen, sku); + offered[kind] = + constraints[kind] === "*" + ? await rolledOutRelease(prefix, sku, deviceId) + : dbReleaseToMetadata(await getReleaseByRange(prefix, sku, constraints[kind]), sku); }), ); @@ -605,7 +622,8 @@ function releaseCacheKey(prefix: string, query: LatestQuery): string { /** * 302 to the newest recovery image for the requested SKU. The product table * says which prefix holds it and what it is called. The route keeps its - * historical name. + * historical name. Only the newest version counts: if it has no image for the + * SKU, the answer is 404 rather than an older image. */ export const RetrieveLatestSystemRecovery = cachedRedirect( query => releaseCacheKey("recovery", query), @@ -617,43 +635,14 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( ); } - const listCommand = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: `${recovery.prefix}/`, - Delimiter: "/", - }); - const response = await s3Client.send(listCommand); - - // Extract version folder names - if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError(`No versions found under prefix ${recovery.prefix} for recovery`); - } - - // Get the latest version - const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]) - .filter(Boolean) - .filter(v => semver.valid(v)); - - const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease: query.prerelease, - }) as string; - - if (!latestVersion) { - throw new NotFoundError(`No valid ${recovery.prefix} recovery versions found`); - } - - const artifactPath = await resolveArtifactPath( - recovery.prefix, - latestVersion, + const { path } = await newestVersionWithArtifact( + recovery, query.sku, - recovery.file, + "*", + query.prerelease, + 1, ); - - if (!(await objectExists(artifactPath))) { - throw new NotFoundError(`Recovery image not found for version ${latestVersion}`); - } - - return `${baseUrl}/${artifactPath}`; + return `${baseUrl}/${path}`; }, ); @@ -664,7 +653,9 @@ export const RetrieveLatestSystemRecovery = cachedRedirect( * object only has to exist. The product table * says which prefix holds it, so the same URL serves every product. Used by * build tooling (rv1106-system pulls the app binary into the system image) - * and by flashing scripts. + * and by flashing scripts. Only the newest version counts: if it has no build + * for the SKU, the answer is 404, so a build fails instead of silently + * packaging an older one. */ function latestArtifactRedirect(kind: OtaKind) { return cachedRedirect( @@ -674,43 +665,15 @@ function latestArtifactRedirect(kind: OtaKind) { if (!artifact) { throw new BadRequestError(`SKU "${query.sku}" has no ${kind} artifact`); } - const { prefix } = artifact; - const listCommand = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: `${prefix}/`, - Delimiter: "/", - }); - const response = await s3Client.send(listCommand); - - if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) { - throw new NotFoundError(`No ${prefix} versions found`); - } - - const versions = response.CommonPrefixes.map(cp => cp.Prefix!.split("/")[1]).filter( - v => semver.valid(v), - ); - - const latestVersion = semver.maxSatisfying(versions, "*", { - includePrerelease: query.prerelease, - }) as string; - - if (!latestVersion) { - throw new NotFoundError(`No valid ${prefix} versions found`); - } - - const artifactPath = await resolveArtifactPath( - prefix, - latestVersion, + const { path } = await newestVersionWithArtifact( + artifact, query.sku, - artifact.file, + "*", + query.prerelease, + 1, ); - - if (!(await objectExists(artifactPath))) { - throw new NotFoundError(`${prefix} artifact not found for version ${latestVersion}`); - } - - return `${baseUrl}/${artifactPath}`; + return `${baseUrl}/${path}`; }, ); } diff --git a/src/webrtc-signaling.ts b/src/webrtc-signaling.ts index c29f788..49ef4d2 100644 --- a/src/webrtc-signaling.ts +++ b/src/webrtc-signaling.ts @@ -33,10 +33,13 @@ export const iceServers = toICEServers( ); // Helper function to update device last seen timestamp +// Runs from socket event handlers, where a rejection would be unhandled async function updateDeviceLastSeen(id: string) { - const device = await prisma.device.findUnique({ where: { id } }); - if (device) { - return prisma.device.update({ where: { id }, data: { lastSeen: new Date() } }); + try { + // updateMany: the device may have been deleted while it disconnected + await prisma.device.updateMany({ where: { id }, data: { lastSeen: new Date() } }); + } catch (error) { + console.error(`[Device] Could not record last seen for ${id}:`, error); } } @@ -48,18 +51,24 @@ export function registerWebSocketRouter( server: Server, ) { server.on("upgrade", async (req: IncomingMessage, socket: Socket, head: Buffer) => { - const url = new URL(req.url || "", "http://localhost"); // We don't care about the hostname, we're just using the path to route - const path = url.pathname; - - // Route to appropriate handler based on path - // This path should be something like /webrtc/signaling/device, but due to legacy reasons we have to use `/` for device ws regitstrations - if (path === "/") { - await handleDeviceSocketRequest(req, socket, head); - } else if (path === "/webrtc/signaling/client") { - await handleClientSocketRequest(req, socket, head); - } else { - console.log(`[Webrtc] Unrecognized path: ${path}`); - return socket.destroy(); + // An event listener: nothing awaits it, so it must not reject + try { + const url = new URL(req.url || "", "http://localhost"); // We don't care about the hostname, we're just using the path to route + const path = url.pathname; + + // Route to appropriate handler based on path + // This path should be something like /webrtc/signaling/device, but due to legacy reasons we have to use `/` for device ws regitstrations + if (path === "/") { + await handleDeviceSocketRequest(req, socket, head); + } else if (path === "/webrtc/signaling/client") { + await handleClientSocketRequest(req, socket, head); + } else { + console.log(`[Webrtc] Unrecognized path: ${path}`); + socket.destroy(); + } + } catch (error) { + console.error("[Webrtc] Error handling upgrade request:", error); + socket.destroy(); } }); } @@ -74,50 +83,45 @@ async function handleDeviceSocketRequest( socket: Socket, head: Buffer, ) { - try { - // Authenticate device - const device = await authenticateDeviceRequest(req); - if (!device) { - return socket.destroy(); - } + // Authenticate device + const device = await authenticateDeviceRequest(req); + if (!device) { + return socket.destroy(); + } - // Inflight means that the device has connected, a client has connected to that device via HTTP, and they're now doing the signaling dance - if (inFlight.has(device.id)) { - console.log( - `[Device] Device ${device.id} already has an inflight client connection.`, - ); - return socket.destroy(); - } + // Inflight means that the device has connected, a client has connected to that device via HTTP, and they're now doing the signaling dance + if (inFlight.has(device.id)) { + console.log( + `[Device] Device ${device.id} already has an inflight client connection.`, + ); + return socket.destroy(); + } - // Handle existing connections for this device - if (activeConnections.has(device.id)) { - console.log( - `[Device] Device ${device.id} already connected. Terminating existing connection.`, - ); - - const existingDeviceWs = activeConnections.get(device.id)!.ws; - await new Promise(resolve => { - console.log("[Device] Waiting for existing connection to close..."); - existingDeviceWs.on("close", () => { - activeConnections.delete(device.id); - console.log("[Device] Existing connection closed."); - - // Now we continue with the new connection - resolve(true); - }); + // Handle existing connections for this device + if (activeConnections.has(device.id)) { + console.log( + `[Device] Device ${device.id} already connected. Terminating existing connection.`, + ); + + const existingDeviceWs = activeConnections.get(device.id)!.ws; + await new Promise(resolve => { + console.log("[Device] Waiting for existing connection to close..."); + existingDeviceWs.on("close", () => { + activeConnections.delete(device.id); + console.log("[Device] Existing connection closed."); - existingDeviceWs.terminate(); + // Now we continue with the new connection + resolve(true); }); - } - // Complete the WebSocket upgrade - wssDevice.handleUpgrade(req, socket, head, ws => { - setupDeviceWebSocket(ws, device, req); + existingDeviceWs.terminate(); }); - } catch (error) { - console.error("Error handling device socket request:", error); - socket.destroy(); } + + // Complete the WebSocket upgrade + wssDevice.handleUpgrade(req, socket, head, ws => { + setupDeviceWebSocket(ws, device, req); + }); } // Authenticate the device connection @@ -190,10 +194,9 @@ function setupDeviceWebSocket(deviceWs: WebSocket, device: Device, req: Incoming // We check for aliveness every 10s }, 10000); - // Handle errors and connection close - deviceWs.on("error", async error => { + // Handle errors and connection close. ws emits "close" after "error", so cleanup runs once, there. + deviceWs.on("error", error => { console.log(`[Device] Error for ${id}:`, error); - await cleanup(); }); deviceWs.on("close", async (code, reason) => { diff --git a/test/releases.test.ts b/test/releases.test.ts index 9d672bc..dd16207 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -80,7 +80,8 @@ function mockS3HashFile( Contents: [], }); - // Mock legacy hash path + // The legacy artifact and its hash + s3Mock.on(HeadObjectCommand, { Key: artifactPath }).resolves({}); s3Mock.on(GetObjectCommand, { Key: `${artifactPath}.sha256` }).resolves({ Body: createAsyncIterable(hash) as any, }); @@ -324,6 +325,34 @@ describe("Retrieve handler", () => { }); describe("prerelease mode", () => { + it("checks only the five newest versions for a SKU that none of them ships", async () => { + // Defaults first: a later mock for the same command takes precedence. + s3Mock.on(ListObjectsV2Command, { MaxKeys: 1 }).resolves({ Contents: [{ Key: "x" }] }); + s3Mock + .on(HeadObjectCommand) + .rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + // 20 app versions with a skus/ folder; only the oldest, 1.0.0, has an SDMMC build. + // The system part has a valid SDMMC build, so only the app part fails. + mockS3ListVersions("app", Array.from({ length: 20 }, (_, i) => `1.${i}.0`)); + mockS3ListVersions("system", ["1.0.0"]); + mockS3SkuVersion("system", "1.0.0", "jetkvm-v2-sdmmc", "system-hash"); + s3Mock + .on(HeadObjectCommand, { Key: "app/1.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app" }) + .resolves({}); + + await expect( + Retrieve( + createMockRequest({ deviceId: "device-123", prerelease: "true", sku: "jetkvm-v2-sdmmc" }), + createMockResponse(), + ), + ).rejects.toThrow(NotFoundError); + // One app version listing, then one skus/ check per version for the newest five + const appListings = s3Mock + .commandCalls(ListObjectsV2Command) + .filter(call => call.args[0].input.Prefix?.startsWith("app/")); + expect(appListings).toHaveLength(1 + 5); + }); + it("should return latest prerelease version when prerelease=true", async () => { const req = createMockRequest({ deviceId: "device-123", prerelease: "true" }); const res = createMockResponse(); @@ -392,6 +421,21 @@ describe("Retrieve handler", () => { }); }); + it("keeps an in-progress rollout when a newer release is registered at 0%", async () => { + // early-adopter hashes to bucket 8, late-adopter to bucket 95. + await createDbReleasePair("2.0.0", 100); + await createDbReleasePair("2.1.0", 50); + await createDbReleasePair("2.2.0", 0); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter" }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ appVersion: "2.1.0", systemVersion: "2.1.0" }); + + const outOfBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "late-adopter" }), outOfBucket); + expect(jsonBody(outOfBucket)).toMatchObject({ appVersion: "2.0.0", systemVersion: "2.0.0" }); + }); + it("applies app and system rollout independently", async () => { await createDbReleasePair("2.4.0", 100); await createDbRelease("app", "2.5.0", 100); @@ -440,6 +484,16 @@ describe("Retrieve handler", () => { }); }); + it("pins only the requested part; the other follows the rollout", async () => { + await createDbReleasePair("3.0.0", 100); + await createDbReleasePair("3.1.0", 0); + + const res = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "pinned-device", appVersion: "3.1.0" }), res); + + expect(jsonBody(res)).toMatchObject({ appVersion: "3.1.0", systemVersion: "3.0.0" }); + }); + it("omits DB-backed stable sigUrl fields when sibling .sig objects are absent", async () => { await createDbReleasePair("3.1.1", 100); mockArtifactSig("app", "3.1.1"); @@ -608,6 +662,21 @@ describe("Retrieve handler", () => { Retrieve(createMockRequest({ deviceId: "late-adopter" }), createMockResponse()), ).rejects.toThrow(/No (app|system) release is rolled out yet for SKU "jetkvm-v2"/); }); + + it("serves the first staged release for a new SKU when no 100% release ships it", async () => { + // The seeded 100% releases carry jetkvm-v2 only. The first SDMMC build + // is staged at 50%: early-adopter (bucket 8) is in, late-adopter (95) out. + await createDbRelease("app", "1.3.0", 50, [releaseArtifact("app", "1.3.0", SDMMC_SKU)]); + await createDbRelease("system", "1.3.0", 50, [releaseArtifact("system", "1.3.0", SDMMC_SKU)]); + + const inBucket = createMockResponse(); + await Retrieve(createMockRequest({ deviceId: "early-adopter", sku: SDMMC_SKU }), inBucket); + expect(jsonBody(inBucket)).toMatchObject({ appVersion: "1.3.0", systemVersion: "1.3.0" }); + + await expect( + Retrieve(createMockRequest({ deviceId: "late-adopter", sku: SDMMC_SKU }), createMockResponse()), + ).rejects.toThrow(/No (app|system) release is rolled out yet for SKU "jetkvm-v2-sdmmc"/); + }); }); describe("signature URL handling", () => { @@ -883,6 +952,20 @@ describe("RetrieveLatestApp S3 redirect handler", () => { clearCaches(); }); + it("answers 404, without an older build, when the newest version has no build for the SKU", async () => { + // 1.1.0 ships only JetKVM v2; 1.0.0 also has an SDMMC build + mockS3ListVersions("app", ["1.0.0", "1.1.0"]); + s3Mock.on(ListObjectsV2Command, { MaxKeys: 1 }).resolves({ Contents: [{ Key: "x" }] }); + s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + s3Mock + .on(HeadObjectCommand, { Key: "app/1.0.0/skus/jetkvm-v2-sdmmc/jetkvm_app" }) + .resolves({}); + + await expect( + RetrieveLatestApp(createMockRequest({ sku: "jetkvm-v2-sdmmc" }), createMockResponse()), + ).rejects.toThrow(NotFoundError); + }); + it("should handle all invalid semver versions gracefully", async () => { const req = createMockRequest({}); const res = createMockResponse(); @@ -1229,6 +1312,23 @@ describe("RetrieveLatestSystemRecovery S3 redirect handler", () => { clearCaches(); }); + it("answers 404, without an older image, when the newest version has no image for the SKU", async () => { + // 1.1.0 ships only JetKVM v2; 1.0.0 also has an SDMMC image + mockS3ListVersions("system", ["1.0.0", "1.1.0"]); + s3Mock.on(ListObjectsV2Command, { MaxKeys: 1 }).resolves({ Contents: [{ Key: "x" }] }); + s3Mock.on(HeadObjectCommand).rejects({ name: "NotFound", $metadata: { httpStatusCode: 404 } }); + s3Mock + .on(HeadObjectCommand, { Key: "system/1.0.0/skus/jetkvm-v2-sdmmc/update_sd.img.zip" }) + .resolves({}); + + await expect( + RetrieveLatestSystemRecovery( + createMockRequest({ sku: "jetkvm-v2-sdmmc" }), + createMockResponse(), + ), + ).rejects.toThrow(NotFoundError); + }); + it("should handle all invalid semver versions gracefully", async () => { const req = createMockRequest({}); const res = createMockResponse(); diff --git a/test/sync-releases.test.ts b/test/sync-releases.test.ts index 3d340d4..87e081b 100644 --- a/test/sync-releases.test.ts +++ b/test/sync-releases.test.ts @@ -201,7 +201,7 @@ describe("syncReleases", () => { expect(artifacts).toEqual([]); }); - it("creates new releases at 10% with their S3 artifacts and skips already-synced versions", async () => { + it("creates new releases at 0% with their S3 artifacts and skips already-synced versions", async () => { const version = "9.9.4"; // Pre-existing system row simulates a release the migration (or a prior @@ -249,8 +249,8 @@ describe("syncReleases", () => { where: { version_type: { version: "10.0.0-beta.1", type: "app" } }, }); - // App release is new — created at 10% rollout with a single legacy-compatible artifact. - expect(appRelease.rolloutPercentage).toBe(10); + // App release is new — created at 0% rollout with a single legacy-compatible artifact. + expect(appRelease.rolloutPercentage).toBe(0); expect(appRelease.artifacts).toEqual([ expect.objectContaining({ url: `https://cdn.test.com/app/${version}/jetkvm_app`, @@ -480,7 +480,7 @@ describe("scheduleReleaseSync", () => { const release = await testPrisma.release.findUnique({ where: { version_type: { version, type: "app" } }, }); - expect(release?.rolloutPercentage).toBe(10); + expect(release?.rolloutPercentage).toBe(0); }); }); @@ -551,7 +551,7 @@ describe("Sync handler", () => { await testPrisma.release.findUnique({ where: { version_type: { version: FRESH_VERSION, type: "app" } }, }), - ).toMatchObject({ rolloutPercentage: 10 }); + ).toMatchObject({ rolloutPercentage: 0 }); expect( await testPrisma.release.findUnique({ where: { version_type: { version: OTHER_FRESH_VERSION, type: "app" } }, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..2bed800 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["e2e/**/*.test.ts"], + globalSetup: ["./e2e/global-setup.ts"], + // One API process and one database for the whole run + fileParallelism: false, + testTimeout: 20000, + hookTimeout: 60000, + }, +});