Files
elizaos--eliza/packages/cloud/api/__tests__/bootstrap-callback-node-identity.test.ts
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

334 lines
10 KiB
TypeScript

/**
* Route tests for the docker-node bootstrap-callback identity guard (#12876).
*
* Exercises the re-bootstrap rule on the EXISTING-node branch: a client cannot
* rewrite a node's SSH identity (hostname/ssh_user/ssh_port) via the shared
* bootstrap secret alone; only a request presenting the pinned host key
* fingerprint may change it. Uses a deterministic in-memory repository stub —
* the guard is pure route logic, so no DB is needed to prove the behavior.
*/
import { beforeEach, describe, expect, mock, test } from "bun:test";
import * as dockerNodesActual from "@/db/repositories/docker-nodes";
import * as loggerActual from "@/lib/utils/logger";
interface StoredNode {
id: string;
node_id: string;
hostname: string;
ssh_port: number;
ssh_user: string;
capacity: number;
host_key_fingerprint: string | null;
status: string;
metadata: Record<string, unknown>;
}
const EXISTING: StoredNode = {
id: "node-row-1",
node_id: "node-1",
hostname: "10.0.0.1",
ssh_port: 22,
ssh_user: "root",
capacity: 8,
host_key_fingerprint: "SHA256:pinned-fingerprint",
status: "healthy",
metadata: { provider: "operator-provisioned" },
};
let stored: StoredNode | null = EXISTING;
let lastUpdateArg: Partial<StoredNode> | null = null;
const mockFindByNodeId = mock(async (_nodeId: string) => stored);
const mockUpdate = mock(async (_id: string, data: Partial<StoredNode>) => {
lastUpdateArg = data;
if (!stored) return null;
stored = { ...stored, ...data };
return stored;
});
const mockCreate = mock(async (data: StoredNode) => {
stored = { ...data, id: "node-row-new" };
return stored;
});
mock.module("@/db/repositories/docker-nodes", () => ({
...dockerNodesActual,
dockerNodesRepository: {
...dockerNodesActual.dockerNodesRepository,
findByNodeId: mockFindByNodeId,
update: mockUpdate,
create: mockCreate,
},
}));
mock.module("@/lib/utils/logger", () => ({
...loggerActual,
logger: {
...loggerActual.logger,
info: mock(),
warn: mock(),
error: mock(),
debug: mock(),
},
}));
const BOOTSTRAP_SECRET = "test-bootstrap-secret";
process.env.CONTAINERS_BOOTSTRAP_SECRET = BOOTSTRAP_SECRET;
const { default: app } = await import(
"../v1/admin/docker-nodes/bootstrap-callback/route"
);
async function post(body: Record<string, unknown>): Promise<Response> {
return app.fetch(
new Request("http://localhost/", {
method: "POST",
headers: {
"content-type": "application/json",
"x-bootstrap-secret": BOOTSTRAP_SECRET,
},
body: JSON.stringify(body),
}),
);
}
describe("bootstrap-callback node-identity guard (#12876)", () => {
beforeEach(() => {
stored = { ...EXISTING, metadata: { ...EXISTING.metadata } };
lastUpdateArg = null;
mockUpdate.mockClear();
mockCreate.mockClear();
mockFindByNodeId.mockClear();
});
test("rejects hostname mutation on existing node without the required fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.6.6.6", // attacker-controlled MITM host
});
expect(res.status).toBe(400);
const json = (await res.json()) as { success: boolean; error: string };
expect(json.success).toBe(false);
expect(json.error).toContain("Validation failed");
// The SSH identity must NOT have been written.
expect(mockUpdate).not.toHaveBeenCalled();
expect(stored?.hostname).toBe("10.0.0.1");
expect(stored?.ssh_user).toBe("root");
expect(stored?.ssh_port).toBe(22);
});
test("rejects ssh_user mutation without a matching fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "attacker",
hostKeyFingerprint: "SHA256:wrong-fingerprint",
});
expect(res.status).toBe(409);
expect(mockUpdate).not.toHaveBeenCalled();
expect(stored?.ssh_user).toBe("root");
});
test("rejects ssh_port mutation without a matching fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshPort: 2222,
hostKeyFingerprint: "SHA256:wrong-fingerprint",
});
expect(res.status).toBe(409);
expect(mockUpdate).not.toHaveBeenCalled();
expect(stored?.ssh_port).toBe(22);
});
test("rejects identity mutation when the node has no pinned fingerprint at all", async () => {
stored = {
...EXISTING,
host_key_fingerprint: null,
metadata: { ...EXISTING.metadata },
};
const res = await post({
nodeId: "node-1",
hostname: "10.6.6.6",
hostKeyFingerprint: "SHA256:anything",
});
expect(res.status).toBe(409);
expect(mockUpdate).not.toHaveBeenCalled();
expect(stored?.hostname).toBe("10.0.0.1");
});
test("allows first host-key pin on an existing autoscaler placeholder when identity is unchanged", async () => {
stored = {
...EXISTING,
host_key_fingerprint: null,
metadata: { provider: "hetzner-cloud", autoscaled: true },
};
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "root",
sshPort: 22,
hostKeyFingerprint: "SHA256:first-real-pin",
});
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(lastUpdateArg?.host_key_fingerprint).toBe("SHA256:first-real-pin");
expect(stored?.host_key_fingerprint).toBe("SHA256:first-real-pin");
});
test("allows identity mutation with a matching pinned fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.9",
sshUser: "deploy",
sshPort: 2200,
hostKeyFingerprint: "SHA256:pinned-fingerprint",
});
expect(res.status).toBe(200);
const json = (await res.json()) as { success: boolean };
expect(json.success).toBe(true);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(lastUpdateArg?.hostname).toBe("10.0.0.9");
expect(lastUpdateArg?.ssh_user).toBe("deploy");
expect(lastUpdateArg?.ssh_port).toBe(2200);
});
test("liveness re-bootstrap with unchanged identity succeeds with the matching fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "root",
sshPort: 22,
capacity: 16,
hostKeyFingerprint: "SHA256:pinned-fingerprint",
});
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledTimes(1);
// Identity preserved. Capacity is operator-owned once the row exists, so a
// re-bootstrap must NOT write it — the request value is ignored here.
expect(lastUpdateArg?.hostname).toBe("10.0.0.1");
expect(lastUpdateArg?.ssh_user).toBe("root");
expect(lastUpdateArg?.ssh_port).toBe(22);
expect(lastUpdateArg).not.toHaveProperty("capacity");
});
test("re-bootstrap preserves an operator-tuned capacity (does not reset to the request/default)", async () => {
// A 252 GB robot the operator hand-tuned to 24 slots via a direct DB write.
stored = { ...EXISTING, capacity: 24, metadata: { ...EXISTING.metadata } };
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "root",
sshPort: 22,
// Callback reports the small-box default; it must not clobber the tune.
capacity: 8,
hostKeyFingerprint: "SHA256:pinned-fingerprint",
});
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(lastUpdateArg).not.toHaveProperty("capacity");
expect(stored?.capacity).toBe(24);
});
test("brand-new node still gets its capacity stamped from the request", async () => {
stored = null; // findByNodeId returns null → insert path
let createdCapacity: number | undefined;
mockCreate.mockImplementationOnce(async (data: StoredNode) => {
createdCapacity = data.capacity;
stored = { ...data, id: "node-row-new" };
return stored;
});
const res = await post({
nodeId: "node-3",
hostname: "10.0.0.60",
sshUser: "root",
sshPort: 22,
capacity: 24,
hostKeyFingerprint: "SHA256:new-node",
});
expect(res.status).toBe(200);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockUpdate).not.toHaveBeenCalled();
expect(createdCapacity).toBe(24);
});
test("rejects liveness re-bootstrap without the required pinned fingerprint", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "root",
sshPort: 22,
});
expect(res.status).toBe(400);
expect(mockUpdate).not.toHaveBeenCalled();
});
test("rejects host-key fingerprint mutation even when SSH identity is unchanged", async () => {
const res = await post({
nodeId: "node-1",
hostname: "10.0.0.1",
sshUser: "root",
sshPort: 22,
hostKeyFingerprint: "SHA256:attacker-first-pin",
});
expect(res.status).toBe(409);
const json = (await res.json()) as { success: boolean; error: string };
expect(json.success).toBe(false);
expect(json.error).toContain("Host key fingerprint");
expect(mockUpdate).not.toHaveBeenCalled();
expect(stored?.host_key_fingerprint).toBe("SHA256:pinned-fingerprint");
});
test("first bootstrap of a brand-new node still creates the row", async () => {
stored = null; // findByNodeId returns null → insert path
const res = await post({
nodeId: "node-2",
hostname: "10.0.0.50",
sshUser: "root",
sshPort: 22,
hostKeyFingerprint: "SHA256:new-node",
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { action: string } };
expect(json.data.action).toBe("created");
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockUpdate).not.toHaveBeenCalled();
});
test("rejects unauthorized requests (bad secret) before any DB access", async () => {
const res = await app.fetch(
new Request("http://localhost/", {
method: "POST",
headers: {
"content-type": "application/json",
"x-bootstrap-secret": "wrong-secret",
},
body: JSON.stringify({ nodeId: "node-1", hostname: "10.6.6.6" }),
}),
);
expect(res.status).toBe(401);
expect(mockFindByNodeId).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});
});