Add ADB management and live car telemetry
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Availability } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_ADB_OUTPUT_BYTES = 16 * 1024;
|
||||
const MAX_ADB_LIST_OUTPUT_BYTES = 512 * 1024;
|
||||
export const MAX_APK_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export type AdbConfig = {
|
||||
enabled: boolean;
|
||||
@@ -48,6 +53,26 @@ export type AdbStatus =
|
||||
| { available: true; value: AdbDeviceSnapshot }
|
||||
| { available: false; state: AdbConnectionState; reason: string };
|
||||
|
||||
export type InstalledAdbPackage = {
|
||||
packageName: string;
|
||||
apkPath: string;
|
||||
system: boolean;
|
||||
};
|
||||
|
||||
export type AdbShortcutTarget =
|
||||
| { type: "component"; value: string }
|
||||
| { type: "package"; value: string };
|
||||
|
||||
export type CarTelemetry = {
|
||||
available: true;
|
||||
collectedAt: string;
|
||||
speedKph: Availability<number>;
|
||||
gear: Availability<string>;
|
||||
wheelAngleDegrees: Availability<number>;
|
||||
batteryPercent: Availability<number>;
|
||||
rangeKm: Availability<number>;
|
||||
};
|
||||
|
||||
const defaultExecutor: AdbExecutor = async (executable, arguments_, options) => {
|
||||
const result = await execFileAsync(executable, [...arguments_], {
|
||||
timeout: options.timeout,
|
||||
@@ -88,6 +113,147 @@ function connectionFailure(error: unknown): Exclude<AdbStatus, { available: true
|
||||
return { available: false, state: "unavailable", reason: "ADB device state could not be collected" };
|
||||
}
|
||||
|
||||
async function resolveSerial(config: AdbConfig, execute: AdbExecutor): Promise<string> {
|
||||
if (!config.enabled) throw new Error("ADB_DISABLED");
|
||||
if (config.serial) return config.serial;
|
||||
const output = (await execute(config.executable, ["devices"], {
|
||||
timeout: config.timeoutMs,
|
||||
maxBuffer: MAX_ADB_OUTPUT_BYTES
|
||||
})).stdout;
|
||||
const devices = output
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.map((line) => line.trim().split(/\s+/, 3))
|
||||
.filter((parts) => parts[0] && parts[1]);
|
||||
if (devices.length !== 1 || devices[0]?.[1] !== "device") throw new Error("ADB_DEVICE_UNAVAILABLE");
|
||||
return devices[0][0]!;
|
||||
}
|
||||
|
||||
async function executeForDevice(
|
||||
config: AdbConfig,
|
||||
arguments_: readonly string[],
|
||||
execute: AdbExecutor,
|
||||
maxBuffer = MAX_ADB_OUTPUT_BYTES,
|
||||
timeout = config.timeoutMs
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const serial = await resolveSerial(config, execute);
|
||||
return execute(config.executable, ["-s", serial, ...arguments_], { timeout, maxBuffer });
|
||||
}
|
||||
|
||||
function validatePackageName(value: string): boolean {
|
||||
return /^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function validateComponent(value: string): boolean {
|
||||
const [packageName, activity, extra] = value.split("/");
|
||||
return !extra && Boolean(packageName && activity) && validatePackageName(packageName!) &&
|
||||
/^(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/.test(activity!);
|
||||
}
|
||||
|
||||
export async function listInstalledAdbPackages(
|
||||
config: AdbConfig,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<InstalledAdbPackage[]> {
|
||||
const output = (await executeForDevice(
|
||||
config,
|
||||
["shell", "pm", "list", "packages", "-f"],
|
||||
execute,
|
||||
MAX_ADB_LIST_OUTPUT_BYTES
|
||||
)).stdout;
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => /^package:(.+)=([a-zA-Z0-9_.]+)$/.exec(line.trim()))
|
||||
.filter((match): match is RegExpExecArray => Boolean(match))
|
||||
.map((match) => ({
|
||||
apkPath: match[1]!,
|
||||
packageName: match[2]!,
|
||||
system: match[1]!.startsWith("/system/") || match[1]!.startsWith("/vendor/") || match[1]!.startsWith("/product/")
|
||||
}))
|
||||
.sort((left, right) => left.packageName.localeCompare(right.packageName));
|
||||
}
|
||||
|
||||
export async function launchAdbTarget(
|
||||
config: AdbConfig,
|
||||
target: AdbShortcutTarget,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<void> {
|
||||
if (target.type === "component") {
|
||||
if (!validateComponent(target.value)) throw new Error("INVALID_ADB_COMPONENT");
|
||||
const result = await executeForDevice(config, ["shell", "am", "start", "-n", target.value], execute);
|
||||
if (/\b(?:error|exception|does not exist)\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("ADB_LAUNCH_FAILED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!validatePackageName(target.value)) throw new Error("INVALID_ADB_PACKAGE");
|
||||
const result = await executeForDevice(
|
||||
config,
|
||||
["shell", "monkey", "-p", target.value, "-c", "android.intent.category.LAUNCHER", "1"],
|
||||
execute
|
||||
);
|
||||
if (/no activities found|monkey aborted|\berror\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("ADB_LAUNCH_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
export async function installAdbApk(
|
||||
config: AdbConfig,
|
||||
apk: Buffer,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<void> {
|
||||
if (apk.byteLength < 4 || apk.byteLength > MAX_APK_BYTES || apk.readUInt32BE(0) !== 0x504b0304) {
|
||||
throw new Error("INVALID_APK");
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-apk-"));
|
||||
const apkPath = join(directory, "upload.apk");
|
||||
try {
|
||||
await writeFile(apkPath, apk, { mode: 0o600 });
|
||||
const serial = await resolveSerial(config, execute);
|
||||
const result = await execute(config.executable, ["-s", serial, "install", "-r", apkPath], {
|
||||
timeout: Math.max(config.timeoutMs, 120_000),
|
||||
maxBuffer: MAX_ADB_OUTPUT_BYTES
|
||||
});
|
||||
if (!/\bSuccess\b/i.test(result.stdout) || /\bFailure\b/i.test(`${result.stdout}\n${result.stderr}`)) {
|
||||
throw new Error("APK_INSTALL_FAILED");
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function parseGetprop(output: string): Map<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match = /^\[([^\]]+)\]: \[(.*)\]$/.exec(line.trim());
|
||||
if (match) properties.set(match[1]!, match[2]!);
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
function numericProperty(properties: Map<string, string>, key: string, label: string): Availability<number> {
|
||||
const raw = properties.get(key);
|
||||
const value = raw === undefined || raw === "" ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(value) ? { available: true, value } : unavailable(`${label} is not exposed by the head unit`);
|
||||
}
|
||||
|
||||
export async function collectCarTelemetry(
|
||||
config: AdbConfig,
|
||||
execute: AdbExecutor = defaultExecutor
|
||||
): Promise<CarTelemetry> {
|
||||
const output = (await executeForDevice(config, ["shell", "getprop"], execute, MAX_ADB_LIST_OUTPUT_BYTES)).stdout;
|
||||
const properties = parseGetprop(output);
|
||||
const rawGear = properties.get("arcsoft.avm.mCurCarGear");
|
||||
return {
|
||||
available: true,
|
||||
collectedAt: new Date().toISOString(),
|
||||
speedKph: numericProperty(properties, "arcsoft.avm.mCurCarSpeed", "Vehicle speed"),
|
||||
gear: rawGear ? { available: true, value: rawGear } : unavailable("Gear is not exposed by the head unit"),
|
||||
wheelAngleDegrees: numericProperty(properties, "arcsoft.avm.mCurCarWheelAngle", "Wheel angle"),
|
||||
batteryPercent: unavailable("Traction battery data requires the SAIC vehicle-service bridge"),
|
||||
rangeKm: unavailable("Range data requires the SAIC vehicle-service bridge")
|
||||
};
|
||||
}
|
||||
|
||||
function parseSize(value: string): Availability<{ widthPixels: number; heightPixels: number }> {
|
||||
const matches = [...value.matchAll(/(?:physical|override) size:\s*(\d+)x(\d+)/gi)];
|
||||
const match = matches.at(-1);
|
||||
|
||||
+154
-1
@@ -28,7 +28,14 @@ import {
|
||||
requestHotspotConfiguration,
|
||||
requestNetworkAction
|
||||
} from "./network.js";
|
||||
import { collectAdbStatus } from "./adb.js";
|
||||
import {
|
||||
collectAdbStatus,
|
||||
collectCarTelemetry,
|
||||
installAdbApk,
|
||||
launchAdbTarget,
|
||||
listInstalledAdbPackages,
|
||||
MAX_APK_BYTES
|
||||
} from "./adb.js";
|
||||
import { openShell } from "./shell.js";
|
||||
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
||||
import "./types.js";
|
||||
@@ -54,6 +61,28 @@ const setupCredentialsSchema = loginCredentialsSchema.extend({
|
||||
.regex(/[0-9]/, "Password must contain at least one number")
|
||||
});
|
||||
|
||||
const adbPackageNameSchema = z.string().trim().max(240).regex(
|
||||
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*$/,
|
||||
"Enter a valid Android package name"
|
||||
);
|
||||
const adbComponentSchema = z.string().trim().max(240).regex(
|
||||
/^(?:[a-zA-Z][a-zA-Z0-9_]*\.)+[a-zA-Z][a-zA-Z0-9_]*\/(?:\.|[a-zA-Z])[a-zA-Z0-9_.$]*$/,
|
||||
"Enter a valid Android component"
|
||||
);
|
||||
const adbTargetSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("component"), value: adbComponentSchema }),
|
||||
z.object({ type: z.literal("package"), value: adbPackageNameSchema })
|
||||
]);
|
||||
const adbShortcutSchema = z.object({ name: z.string().trim().min(1).max(48), type: z.enum(["component", "package"]), value: z.string() })
|
||||
.transform((value, context) => {
|
||||
const target = adbTargetSchema.safeParse({ type: value.type, value: value.value });
|
||||
if (!target.success) {
|
||||
context.addIssue({ code: "custom", message: target.error.issues[0]?.message ?? "Enter a valid Android target" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return { name: value.name, ...target.data };
|
||||
});
|
||||
|
||||
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
|
||||
type SessionUserRow = { id: number; username: string; role: "admin" };
|
||||
|
||||
@@ -118,6 +147,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(websocket);
|
||||
app.addContentTypeParser(
|
||||
["application/vnd.android.package-archive", "application/octet-stream"],
|
||||
{ parseAs: "buffer" },
|
||||
(_request, body, done) => done(null, body)
|
||||
);
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
jobRunner.close();
|
||||
@@ -335,6 +369,125 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return readNetworkActivity(config.network.activityPath);
|
||||
});
|
||||
|
||||
app.get("/api/adb/packages", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
try {
|
||||
return { packages: await listInstalledAdbPackages(config.adb) };
|
||||
} catch {
|
||||
return reply.code(503).send({ error: { code: "ADB_UNAVAILABLE", message: "The infotainment unit is not available over ADB" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/adb/install",
|
||||
{ config: { rateLimit: { max: 4, timeWindow: "10 minutes" } }, bodyLimit: MAX_APK_BYTES },
|
||||
async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
if (!Buffer.isBuffer(request.body)) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_APK", message: "Choose a valid APK file" } });
|
||||
}
|
||||
try {
|
||||
await installAdbApk(config.adb, request.body);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.install-apk",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { bytes: request.body.byteLength }
|
||||
});
|
||||
return reply.code(201).send({ installed: true });
|
||||
} catch (error) {
|
||||
const invalid = error instanceof Error && error.message === "INVALID_APK";
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.install-apk",
|
||||
result: "failure",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { reason: invalid ? "invalid_apk" : "install_failed" }
|
||||
});
|
||||
return reply.code(invalid ? 400 : 503).send({
|
||||
error: {
|
||||
code: invalid ? "INVALID_APK" : "APK_INSTALL_FAILED",
|
||||
message: invalid ? "Choose a valid APK file" : "The APK could not be installed on the infotainment unit"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/api/adb/launch", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = adbTargetSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_TARGET", message: "Enter a valid package or activity" } });
|
||||
}
|
||||
try {
|
||||
await launchAdbTarget(config.adb, parsed.data);
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.launch",
|
||||
result: "success",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { targetType: parsed.data.type, target: parsed.data.value }
|
||||
});
|
||||
return { launched: true };
|
||||
} catch {
|
||||
audit(database, {
|
||||
actorUserId: request.authUser!.id,
|
||||
action: "adb.launch",
|
||||
result: "failure",
|
||||
ipAddress: clientAddress(request),
|
||||
details: { targetType: parsed.data.type, target: parsed.data.value }
|
||||
});
|
||||
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The target could not be opened on the infotainment unit" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/adb/shortcuts", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const shortcuts = database
|
||||
.prepare("SELECT id, name, target_type AS type, target_value AS value, created_at AS createdAt FROM adb_shortcuts ORDER BY created_at, id")
|
||||
.all();
|
||||
return { shortcuts };
|
||||
});
|
||||
|
||||
app.post("/api/adb/shortcuts", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const parsed = adbShortcutSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "Enter a name and a valid target" } });
|
||||
}
|
||||
try {
|
||||
const createdAt = new Date().toISOString();
|
||||
const result = database
|
||||
.prepare("INSERT INTO adb_shortcuts (name, target_type, target_value, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(parsed.data.name, parsed.data.type, parsed.data.value, createdAt);
|
||||
return reply.code(201).send({ id: Number(result.lastInsertRowid), ...parsed.data, createdAt });
|
||||
} catch {
|
||||
return reply.code(409).send({ error: { code: "ADB_SHORTCUT_EXISTS", message: "A shortcut for that target already exists" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/adb/shortcuts/:id", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
const id = Number(request.params.id);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return reply.code(400).send({ error: { code: "INVALID_ADB_SHORTCUT", message: "That shortcut is invalid" } });
|
||||
}
|
||||
const result = database.prepare("DELETE FROM adb_shortcuts WHERE id = ?").run(id);
|
||||
if (result.changes === 0) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get("/api/car/telemetry", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
try {
|
||||
return await collectCarTelemetry(config.adb);
|
||||
} catch {
|
||||
return reply.code(503).send({ error: { code: "CAR_TELEMETRY_UNAVAILABLE", message: "Live car telemetry is unavailable over ADB" } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/network/configuration",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
|
||||
@@ -60,6 +60,15 @@ function migrate(database: CompanionDatabase): void {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS job_runs_created_idx ON job_runs(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adb_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK (target_type IN ('component', 'package')),
|
||||
target_value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(target_type, target_value)
|
||||
);
|
||||
`);
|
||||
|
||||
database
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectAdbStatus, type AdbConfig, type AdbExecutor } from "../src/adb.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
collectAdbStatus,
|
||||
collectCarTelemetry,
|
||||
installAdbApk,
|
||||
launchAdbTarget,
|
||||
listInstalledAdbPackages,
|
||||
type AdbConfig,
|
||||
type AdbExecutor
|
||||
} from "../src/adb.js";
|
||||
|
||||
const enabledConfig: AdbConfig = {
|
||||
enabled: true,
|
||||
@@ -129,3 +138,63 @@ describe("read-only ADB collector", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADB dashboard operations", () => {
|
||||
it("lists installed packages and identifies system paths", async () => {
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
expect(arguments_).toEqual(["-s", "MG4-HEAD-UNIT", "shell", "pm", "list", "packages", "-f"]);
|
||||
return {
|
||||
stdout: "package:/data/app/com.example/base.apk=com.example\npackage:/system/app/Settings/Settings.apk=com.android.settings\n",
|
||||
stderr: ""
|
||||
};
|
||||
};
|
||||
await expect(listInstalledAdbPackages(enabledConfig, execute)).resolves.toEqual([
|
||||
{ packageName: "com.android.settings", apkPath: "/system/app/Settings/Settings.apk", system: true },
|
||||
{ packageName: "com.example", apkPath: "/data/app/com.example/base.apk", system: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it("launches validated packages and components without a shell", async () => {
|
||||
const calls: string[][] = [];
|
||||
const execute: AdbExecutor = async (_executable, arguments_) => {
|
||||
calls.push([...arguments_]);
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
await launchAdbTarget(enabledConfig, { type: "component", value: "com.android.settings/.Settings" }, execute);
|
||||
await launchAdbTarget(enabledConfig, { type: "package", value: "com.example.app" }, execute);
|
||||
expect(calls).toEqual([
|
||||
["-s", "MG4-HEAD-UNIT", "shell", "am", "start", "-n", "com.android.settings/.Settings"],
|
||||
["-s", "MG4-HEAD-UNIT", "shell", "monkey", "-p", "com.example.app", "-c", "android.intent.category.LAUNCHER", "1"]
|
||||
]);
|
||||
await expect(launchAdbTarget(enabledConfig, { type: "component", value: "com.example/.Main;reboot" }, execute)).rejects.toThrow("INVALID_ADB_COMPONENT");
|
||||
});
|
||||
|
||||
it("parses the verified MG4 live properties and marks SAIC-only data unavailable", async () => {
|
||||
const execute: AdbExecutor = async () => ({
|
||||
stdout: "[arcsoft.avm.mCurCarSpeed]: [17]\n[arcsoft.avm.mCurCarGear]: [4]\n[arcsoft.avm.mCurCarWheelAngle]: [-12.5]\n",
|
||||
stderr: ""
|
||||
});
|
||||
await expect(collectCarTelemetry(enabledConfig, execute)).resolves.toMatchObject({
|
||||
available: true,
|
||||
speedKph: { available: true, value: 17 },
|
||||
gear: { available: true, value: "4" },
|
||||
wheelAngleDegrees: { available: true, value: -12.5 },
|
||||
batteryPercent: { available: false },
|
||||
rangeKm: { available: false }
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts only ZIP-formatted APK payloads and removes the temporary file", async () => {
|
||||
let temporaryPath = "";
|
||||
const execute: AdbExecutor = async (_executable, arguments_, options) => {
|
||||
temporaryPath = arguments_.at(-1) ?? "";
|
||||
expect(arguments_.slice(0, 4)).toEqual(["-s", "MG4-HEAD-UNIT", "install", "-r"]);
|
||||
expect(existsSync(temporaryPath)).toBe(true);
|
||||
expect(options.timeout).toBe(120_000);
|
||||
return { stdout: "Success\n", stderr: "" };
|
||||
};
|
||||
await installAdbApk(enabledConfig, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00]), execute);
|
||||
expect(existsSync(temporaryPath)).toBe(false);
|
||||
await expect(installAdbApk(enabledConfig, Buffer.from("not an apk"), execute)).rejects.toThrow("INVALID_APK");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,56 @@ describe("authentication boundary", () => {
|
||||
expect(invalidConfiguration.json()).toMatchObject({ error: { code: "INVALID_HOTSPOT_CONFIGURATION" } });
|
||||
});
|
||||
|
||||
it("protects ADB operations and persists only validated shortcuts", async () => {
|
||||
const app = await createApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/adb/packages" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/adb/shortcuts" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/car/telemetry" })).statusCode).toBe(401);
|
||||
|
||||
const token = await csrf(app);
|
||||
await setup(app, token);
|
||||
const login = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
headers: mutationHeaders(token),
|
||||
payload: { username: "owner", password: "Correct-horse1" }
|
||||
});
|
||||
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
|
||||
const cookie = `${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=${session}`;
|
||||
const headers = { ...mutationHeaders(token), cookie };
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/shortcuts",
|
||||
headers,
|
||||
payload: { name: "Unsafe", type: "component", value: "com.example/.Main;reboot" }
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
|
||||
const created = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/shortcuts",
|
||||
headers,
|
||||
payload: { name: "Settings", type: "component", value: "com.android.settings/.Settings" }
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const shortcutId = created.json<{ id: number }>().id;
|
||||
const shortcuts = await app.inject({ method: "GET", url: "/api/adb/shortcuts", headers: { cookie } });
|
||||
expect(shortcuts.json()).toMatchObject({ shortcuts: [{ id: shortcutId, name: "Settings" }] });
|
||||
|
||||
const badApk = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/adb/install",
|
||||
headers: { ...headers, "content-type": "application/vnd.android.package-archive" },
|
||||
payload: Buffer.from("plain text")
|
||||
});
|
||||
expect(badApk.statusCode).toBe(400);
|
||||
expect(badApk.json()).toMatchObject({ error: { code: "INVALID_APK" } });
|
||||
|
||||
const removed = await app.inject({ method: "DELETE", url: `/api/adb/shortcuts/${shortcutId}`, headers });
|
||||
expect(removed.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||
const app = await createApp();
|
||||
const token = await csrf(app);
|
||||
|
||||
Reference in New Issue
Block a user