Add resilient dual-radio network failover
This commit is contained in:
@@ -17,6 +17,11 @@ function testConfig(): AppConfig {
|
||||
allowedOrigins: new Set(["http://mg4pi.local:8787"]),
|
||||
updateStatusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-update.json`,
|
||||
versionFile: `/tmp/pi-car-companion-test-${process.pid}-missing-revision`,
|
||||
network: {
|
||||
enabled: false,
|
||||
statusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-network-status.json`,
|
||||
commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json`
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
}
|
||||
@@ -180,6 +185,33 @@ describe("authentication boundary", () => {
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("keeps network status and controls behind authentication and explicit configuration", async () => {
|
||||
const app = await createApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/network" })).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 status = await app.inject({ method: "GET", url: "/api/network", headers: { cookie } });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toMatchObject({ supported: false, enabled: false, mode: "disabled" });
|
||||
|
||||
const action = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/network/actions",
|
||||
headers: { ...mutationHeaders(token), cookie },
|
||||
payload: { action: "retry-upstream" }
|
||||
});
|
||||
expect(action.statusCode).toBe(503);
|
||||
});
|
||||
|
||||
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||
const app = await createApp();
|
||||
const token = await csrf(app);
|
||||
|
||||
@@ -21,4 +21,12 @@ describe("ADB configuration", () => {
|
||||
serial: "10.0.0.10:5555"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps automatic network management disabled by default", () => {
|
||||
expect(loadConfig({ NODE_ENV: "test" }).network).toEqual({
|
||||
enabled: false,
|
||||
statusPath: "/var/lib/pi-car-companion/network-status.json",
|
||||
commandPath: "/var/lib/pi-car-companion/network-command.json"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import { consumeNetworkCommand, readNetworkStatus, requestNetworkAction } from "../src/network.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
function config(directory: string, enabled = true): AppConfig {
|
||||
return {
|
||||
nodeEnv: "test",
|
||||
host: "127.0.0.1",
|
||||
port: 8787,
|
||||
databasePath: ":memory:",
|
||||
sessionTtlMs: 60_000,
|
||||
cookieSecure: false,
|
||||
allowedOrigins: new Set(),
|
||||
updateStatusPath: join(directory, "update.json"),
|
||||
versionFile: join(directory, "REVISION"),
|
||||
network: { enabled, statusPath: join(directory, "status.json"), commandPath: join(directory, "command.json") },
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("network control boundary", () => {
|
||||
it("writes and consumes only fixed action requests", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-network-test-"));
|
||||
directories.push(directory);
|
||||
const testConfig = config(directory);
|
||||
const requested = await requestNetworkAction(testConfig, "retry-upstream", 7);
|
||||
expect(JSON.parse(await readFile(testConfig.network.commandPath, "utf8"))).toMatchObject({
|
||||
id: requested.id,
|
||||
action: "retry-upstream",
|
||||
actorUserId: 7
|
||||
});
|
||||
await expect(consumeNetworkCommand(testConfig.network.commandPath)).resolves.toMatchObject({ action: "retry-upstream" });
|
||||
await expect(consumeNetworkCommand(testConfig.network.commandPath)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed controller status", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-network-test-"));
|
||||
directories.push(directory);
|
||||
const testConfig = config(directory);
|
||||
await writeFile(testConfig.network.statusPath, '{"mode":"shell"}');
|
||||
await expect(readNetworkStatus(testConfig)).resolves.toMatchObject({ supported: false, mode: "starting" });
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
|
||||
allowedOrigins: new Set(),
|
||||
updateStatusPath,
|
||||
versionFile,
|
||||
network: {
|
||||
enabled: false,
|
||||
statusPath: join(tmpdir(), "missing-network-status.json"),
|
||||
commandPath: join(tmpdir(), "network-command.json")
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user