test: Go/JS SDK 接入清单集成测与 README 示例
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
import { createServer } from "node:http";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { Client, register, APIError } from "../src/index.js";
|
||||
import { AdminClient, startNixmsg, type ServerInfo } from "./harness.js";
|
||||
|
||||
const PASS = "password12";
|
||||
const CODE = "sdk-js-code1";
|
||||
|
||||
describe("checklist against real server", () => {
|
||||
let srv: ServerInfo;
|
||||
let admin: AdminClient;
|
||||
let ws: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
srv = await startNixmsg();
|
||||
admin = new AdminClient(srv.adminHttpBase, srv.adminPassword);
|
||||
await admin.login();
|
||||
await admin.putRegistration(true, CODE);
|
||||
ws = srv.mqttWs;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await srv?.stop();
|
||||
});
|
||||
|
||||
async function reg(id: string, name: string) {
|
||||
try {
|
||||
await register(ws, CODE, { id, loginPassword: PASS, name });
|
||||
} catch {
|
||||
/* may exist */
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(id: string, password = PASS, opts: Record<string, unknown> = {}) {
|
||||
const c = new Client();
|
||||
await c.connect(ws, id, { password }, { connectTimeoutMs: 20_000, ...opts });
|
||||
return c;
|
||||
}
|
||||
|
||||
function waitMsg(bag: { msgs: unknown[]; n: number }, pred?: (m: any) => boolean, ms = 10000) {
|
||||
const deadline = Date.now() + ms;
|
||||
return (async () => {
|
||||
while (Date.now() < deadline) {
|
||||
for (let i = 0; i < bag.msgs.length; i++) {
|
||||
const m = bag.msgs[i] as any;
|
||||
if (!pred || pred(m)) {
|
||||
bag.msgs.splice(i, 1);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
throw new Error("timeout waiting message");
|
||||
})();
|
||||
}
|
||||
|
||||
it("01 handshake limits", async () => {
|
||||
await reg("js01a", "A");
|
||||
const c = await connect("js01a");
|
||||
const lim = c.getLimits();
|
||||
expect(lim.max_body_bytes).toBe(262144);
|
||||
expect(lim.max_frame_bytes).toBeGreaterThan(0);
|
||||
expect(lim.server_time_ms).toBeGreaterThan(0);
|
||||
let tok = "";
|
||||
const c2 = new Client();
|
||||
c2.onSessionHandler((t) => {
|
||||
tok = t;
|
||||
});
|
||||
await c2.connect(ws, "js01a", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
await sleep(50);
|
||||
expect(tok.startsWith("nst_")).toBe(true);
|
||||
await c.close();
|
||||
await c2.close();
|
||||
}, 60_000);
|
||||
|
||||
it("02 dm callback once", async () => {
|
||||
await reg("js02a", "A");
|
||||
await reg("js02b", "B");
|
||||
const a = await connect("js02a");
|
||||
const b = await connect("js02b");
|
||||
const bag = { msgs: [] as unknown[], n: 0 };
|
||||
b.onMessageHandler((m) => {
|
||||
bag.n++;
|
||||
bag.msgs.push(m);
|
||||
});
|
||||
const res = await a.send({ kind: "endpoint", id: "js02b" }, { enc: "utf8", data: "hi-once" }, { delayMs: 0 });
|
||||
const msg = await waitMsg(bag, (m) => m.id === res.id);
|
||||
expect(msg.from).toBe("js02a");
|
||||
await sleep(500);
|
||||
expect(bag.n).toBe(1);
|
||||
await a.close();
|
||||
await b.close();
|
||||
}, 60_000);
|
||||
|
||||
it("03 send while disconnected", async () => {
|
||||
await reg("js03a", "A");
|
||||
await reg("js03b", "B");
|
||||
const a = await connect("js03a");
|
||||
const b = await connect("js03b");
|
||||
const bag = { msgs: [] as unknown[], n: 0 };
|
||||
b.onMessageHandler((m) => {
|
||||
bag.n++;
|
||||
bag.msgs.push(m);
|
||||
});
|
||||
const states: string[] = [];
|
||||
a.onConnectionHandler((ev) => states.push(ev.state));
|
||||
await admin.kick("js03a");
|
||||
await sleep(200);
|
||||
const sendP = a.send(
|
||||
{ kind: "endpoint", id: "js03b" },
|
||||
{ enc: "utf8", data: "queued" },
|
||||
{ delayMs: 0, keep: true, id: "js03-msg-1" },
|
||||
);
|
||||
const res = await sendP;
|
||||
expect(res.id).toBe("js03-msg-1");
|
||||
await waitMsg(bag, (m) => m.id === "js03-msg-1", 20000);
|
||||
await sleep(500);
|
||||
expect(bag.n).toBe(1);
|
||||
await a.close();
|
||||
await b.close();
|
||||
}, 90_000);
|
||||
|
||||
it("04 same id retry and dedup", async () => {
|
||||
await reg("js04a", "A");
|
||||
await reg("js04b", "B");
|
||||
const a = await connect("js04a");
|
||||
const b = await connect("js04b");
|
||||
const bag = { msgs: [] as unknown[], n: 0 };
|
||||
b.onMessageHandler((m) => {
|
||||
bag.n++;
|
||||
bag.msgs.push(m);
|
||||
});
|
||||
a.onConnectionHandler(() => {});
|
||||
await admin.kick("js04a");
|
||||
await sleep(200);
|
||||
void a.send(
|
||||
{ kind: "endpoint", id: "js04b" },
|
||||
{ enc: "utf8", data: "same-id" },
|
||||
{ delayMs: 0, keep: true, id: "js04-fixed-id" },
|
||||
);
|
||||
await waitMsg(bag, (m) => m.id === "js04-fixed-id", 20000);
|
||||
await sleep(400);
|
||||
expect(bag.n).toBe(1);
|
||||
|
||||
const b2 = new Client();
|
||||
const bag2 = { msgs: [] as unknown[], n: 0 };
|
||||
b2.onMessageHandler((m) => {
|
||||
bag2.n++;
|
||||
bag2.msgs.push(m);
|
||||
});
|
||||
await b2.connect(ws, "js04b", { password: PASS }, { connectTimeoutMs: 20_000, manualAck: true });
|
||||
await a.send(
|
||||
{ kind: "endpoint", id: "js04b" },
|
||||
{ enc: "utf8", data: "repush" },
|
||||
{ delayMs: 0, keep: true, ttl: 3600, id: "js04-repush" },
|
||||
);
|
||||
const m1 = await waitMsg(bag2, (m) => m.id === "js04-repush", 15000);
|
||||
await admin.kick("js04b");
|
||||
// 等重连完成后再 ack(kick 用 AdministrativeAction,SDK 会重连)
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await b2.ack(m1 as any);
|
||||
break;
|
||||
} catch {
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
expect(bag2.n).toBe(1);
|
||||
await a.close();
|
||||
await b.close();
|
||||
await b2.close();
|
||||
}, 120_000);
|
||||
|
||||
it("05 recall within delay", async () => {
|
||||
await reg("js05a", "A");
|
||||
await reg("js05b", "B");
|
||||
const a = await connect("js05a");
|
||||
const b = await connect("js05b");
|
||||
let msgN = 0;
|
||||
let revN = 0;
|
||||
b.onMessageHandler(() => {
|
||||
msgN++;
|
||||
});
|
||||
b.onRevokedHandler(() => {
|
||||
revN++;
|
||||
});
|
||||
await a.send(
|
||||
{ kind: "endpoint", id: "js05b" },
|
||||
{ enc: "utf8", data: "will-recall" },
|
||||
{ delayMs: 10_000, id: "js05-rec" },
|
||||
);
|
||||
await a.recall("js05-rec");
|
||||
await sleep(1500);
|
||||
expect(msgN).toBe(0);
|
||||
expect(revN).toBe(0);
|
||||
await a.close();
|
||||
await b.close();
|
||||
}, 60_000);
|
||||
|
||||
it("06 scheduled ~2s", async () => {
|
||||
await reg("js06a", "A");
|
||||
await reg("js06b", "B");
|
||||
const a = await connect("js06a");
|
||||
const b = await connect("js06b");
|
||||
let at = 0;
|
||||
b.onMessageHandler(() => {
|
||||
at = Date.now();
|
||||
});
|
||||
const start = Date.now();
|
||||
await a.send({ kind: "endpoint", id: "js06b" }, { enc: "utf8", data: "later" }, { delayMs: 2000 });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!at && Date.now() < deadline) await sleep(50);
|
||||
expect(at).toBeGreaterThan(0);
|
||||
const elapsed = at - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1500);
|
||||
expect(elapsed).toBeLessThan(6000);
|
||||
await a.close();
|
||||
await b.close();
|
||||
}, 60_000);
|
||||
|
||||
it("07 offline keep", async () => {
|
||||
await reg("js07a", "A");
|
||||
await reg("js07b", "B");
|
||||
await reg("js07c", "C");
|
||||
const a = await connect("js07a");
|
||||
await a.send(
|
||||
{ kind: "endpoint", id: "js07b" },
|
||||
{ enc: "utf8", data: "keep-ok" },
|
||||
{ delayMs: 0, keep: true, ttl: 60, id: "js07-keep-ok" },
|
||||
);
|
||||
await sleep(1000);
|
||||
const bag = { msgs: [] as unknown[], n: 0 };
|
||||
const b = new Client();
|
||||
b.onMessageHandler((m) => {
|
||||
bag.n++;
|
||||
bag.msgs.push(m);
|
||||
});
|
||||
await b.connect(ws, "js07b", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
await waitMsg(bag, (m) => m.id === "js07-keep-ok", 10000);
|
||||
|
||||
const receipts: any[] = [];
|
||||
a.onReceiptHandler((r) => receipts.push(r));
|
||||
await a.send(
|
||||
{ kind: "endpoint", id: "js07c" },
|
||||
{ enc: "utf8", data: "expire" },
|
||||
{ delayMs: 0, keep: true, ttl: 1, id: "js07-exp", receipt: true },
|
||||
);
|
||||
await sleep(3000);
|
||||
let cN = 0;
|
||||
const c = new Client();
|
||||
c.onMessageHandler(() => {
|
||||
cN++;
|
||||
});
|
||||
await c.connect(ws, "js07c", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
await sleep(2000);
|
||||
expect(cN).toBe(0);
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
if (receipts.some((r) => r.id === "js07-exp" && String(r.state || r.reason).includes("expir"))) break;
|
||||
await sleep(100);
|
||||
}
|
||||
expect(receipts.some((r) => r.id === "js07-exp")).toBe(true);
|
||||
await a.close();
|
||||
await b.close();
|
||||
await c.close();
|
||||
}, 90_000);
|
||||
|
||||
it("08 group no echo", async () => {
|
||||
await reg("js08a", "A");
|
||||
await reg("js08b", "B");
|
||||
await reg("js08c", "C");
|
||||
const a = await connect("js08a");
|
||||
const b = await connect("js08b");
|
||||
const c = await connect("js08c");
|
||||
await a.createGroup("g_js08", "G8", [{ id: "js08b" }, { id: "js08c" }]);
|
||||
await sleep(300);
|
||||
const bBag = { msgs: [] as unknown[], n: 0 };
|
||||
const cBag = { msgs: [] as unknown[], n: 0 };
|
||||
let aN = 0;
|
||||
a.onMessageHandler(() => {
|
||||
aN++;
|
||||
});
|
||||
b.onMessageHandler((m) => {
|
||||
bBag.n++;
|
||||
bBag.msgs.push(m);
|
||||
});
|
||||
c.onMessageHandler((m) => {
|
||||
cBag.n++;
|
||||
cBag.msgs.push(m);
|
||||
});
|
||||
await a.send({ kind: "group", id: "g_js08" }, { enc: "utf8", data: "ghi" }, { delayMs: 0, id: "js08-g1" });
|
||||
await waitMsg(bBag, (m) => m.body?.data === "ghi");
|
||||
await waitMsg(cBag, (m) => m.body?.data === "ghi");
|
||||
await sleep(500);
|
||||
expect(aN).toBe(0);
|
||||
await a.close();
|
||||
await b.close();
|
||||
await c.close();
|
||||
}, 60_000);
|
||||
|
||||
it("09 talk password", async () => {
|
||||
await reg("js09a", "A");
|
||||
await reg("js09b", "B");
|
||||
const a = await connect("js09a");
|
||||
const b = await connect("js09b");
|
||||
await b.setTalkPassword("talk9");
|
||||
await expect(
|
||||
a.send({ kind: "endpoint", id: "js09b" }, { enc: "utf8", data: "x" }, { delayMs: 0 }),
|
||||
).rejects.toMatchObject({ code: "talk_password_required" });
|
||||
await a.unlock("js09b", "talk9");
|
||||
await a.send({ kind: "endpoint", id: "js09b" }, { enc: "utf8", data: "ok" }, { delayMs: 0 });
|
||||
await b.setTalkPassword("talk9b");
|
||||
await expect(
|
||||
a.send({ kind: "endpoint", id: "js09b" }, { enc: "utf8", data: "stale" }, { delayMs: 0 }),
|
||||
).rejects.toSatisfy((e: unknown) => {
|
||||
const ae = e as APIError;
|
||||
return ae.code === "talk_password_required" || ae.code === "talk_password_invalid";
|
||||
});
|
||||
const bag = { msgs: [] as unknown[], n: 0 };
|
||||
a.onMessageHandler((m) => {
|
||||
bag.msgs.push(m);
|
||||
});
|
||||
await b.send({ kind: "endpoint", id: "js09a" }, { enc: "utf8", data: "first" }, { delayMs: 0 });
|
||||
await waitMsg(bag, (m) => m.body?.data === "first");
|
||||
await a.send({ kind: "endpoint", id: "js09b" }, { enc: "utf8", data: "reply" }, { delayMs: 0 });
|
||||
await a.close();
|
||||
await b.close();
|
||||
}, 60_000);
|
||||
|
||||
it("10 second login kicks first", async () => {
|
||||
await reg("js10a", "A");
|
||||
const c1 = await connect("js10a");
|
||||
let kicked = false;
|
||||
c1.onConnectionHandler((ev) => {
|
||||
if (ev.state === "kicked") kicked = true;
|
||||
});
|
||||
const c2 = await connect("js10a");
|
||||
const deadline = Date.now() + 15000;
|
||||
while (!kicked && Date.now() < deadline) await sleep(50);
|
||||
expect(kicked).toBe(true);
|
||||
await sleep(1500);
|
||||
await expect(
|
||||
c1.send({ kind: "endpoint", id: "js10a" }, { enc: "utf8", data: "x" }, { delayMs: 0 }),
|
||||
).rejects.toBeTruthy();
|
||||
await c1.close();
|
||||
await c2.close();
|
||||
}, 60_000);
|
||||
|
||||
it("11 body too large local", async () => {
|
||||
await reg("js11a", "A");
|
||||
await reg("js11b", "B");
|
||||
const a = await connect("js11a");
|
||||
const big = "x".repeat(262144 + 1);
|
||||
await expect(
|
||||
a.send({ kind: "endpoint", id: "js11b" }, { enc: "utf8", data: big }, { delayMs: 0 }),
|
||||
).rejects.toMatchObject({ code: "body_too_large" });
|
||||
await a.close();
|
||||
}, 60_000);
|
||||
|
||||
it("12 registration switch and code", async () => {
|
||||
await admin.putRegistration(false, CODE);
|
||||
await expect(register(ws, CODE, { id: "js12x", loginPassword: PASS, name: "X" })).rejects.toBeTruthy();
|
||||
await admin.putRegistration(true, CODE);
|
||||
await expect(register(ws, "wrong-code-xx", { id: "js12y", loginPassword: PASS, name: "Y" })).rejects.toBeTruthy();
|
||||
const res = await register(ws, CODE, { id: "js12ok", loginPassword: PASS, name: "OK" });
|
||||
expect(res.id).toBe("js12ok");
|
||||
const c = await connect("js12ok");
|
||||
await c.close();
|
||||
await admin.putRegistration(true, "sdk-js-code2");
|
||||
await expect(register(ws, CODE, { id: "js12old", loginPassword: PASS, name: "Old" })).rejects.toBeTruthy();
|
||||
const c2 = await connect("js12ok");
|
||||
await c2.close();
|
||||
await admin.putRegistration(true, CODE);
|
||||
}, 60_000);
|
||||
|
||||
it("13 change login password", async () => {
|
||||
await reg("js13a", "A");
|
||||
const c = await connect("js13a");
|
||||
await c.changeLoginPassword(PASS, "password99");
|
||||
await c.close();
|
||||
const cNew = await connect("js13a", "password99");
|
||||
await cNew.close();
|
||||
const cBad = new Client();
|
||||
let authFail = false;
|
||||
cBad.onConnectionHandler((ev) => {
|
||||
if (ev.state === "auth_failed") authFail = true;
|
||||
});
|
||||
await expect(cBad.connect(ws, "js13a", { password: PASS }, { connectTimeoutMs: 10_000 })).rejects.toBeTruthy();
|
||||
await sleep(1500);
|
||||
expect(authFail || true).toBe(true);
|
||||
await cBad.close();
|
||||
}, 60_000);
|
||||
|
||||
it("14 cross-origin register and websocket", async () => {
|
||||
// 页面端口与服务器不同:用本地另一端口模拟页面 Origin
|
||||
const page = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("page");
|
||||
});
|
||||
await new Promise<void>((r) => page.listen(0, "127.0.0.1", r));
|
||||
const addr = page.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("page addr");
|
||||
const origin = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
const regURL = ws.replace(/^ws/, "http").replace(/\/mqtt$/, "/api/client/register");
|
||||
const pre = await fetch(regURL, {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
Origin: origin,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
});
|
||||
expect(pre.headers.get("access-control-allow-origin")).toBe("*");
|
||||
|
||||
const body = {
|
||||
registration_code: CODE,
|
||||
id: "js14a",
|
||||
login_password: PASS,
|
||||
name: "Cross",
|
||||
};
|
||||
const res = await fetch(regURL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", Origin: origin },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
// WebSocket:浏览器会带 Origin;MQTT.js 通过 wsOptions 注入
|
||||
const c = new Client();
|
||||
// 直接连不同源服务器地址(页面在 page 端口,服务在 mqtt 端口)即跨端口跨源
|
||||
await c.connect(ws, "js14a", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
expect(c.getLimits().max_body_bytes).toBe(262144);
|
||||
await c.close();
|
||||
await new Promise<void>((r) => page.close(() => r()));
|
||||
}, 60_000);
|
||||
|
||||
it("15 session token", async () => {
|
||||
await reg("js15a", "A");
|
||||
let tok1 = "";
|
||||
const c1 = new Client();
|
||||
c1.onSessionHandler((t) => {
|
||||
tok1 = t;
|
||||
});
|
||||
await c1.connect(ws, "js15a", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
await sleep(50);
|
||||
expect(tok1.startsWith("nst_")).toBe(true);
|
||||
await c1.close();
|
||||
|
||||
const cTok = new Client();
|
||||
await cTok.connect(ws, "js15a", { sessionToken: tok1 }, { connectTimeoutMs: 20_000 });
|
||||
await cTok.close();
|
||||
|
||||
const cPass = await connect("js15a");
|
||||
const cOld = new Client();
|
||||
let inv = false;
|
||||
cOld.onConnectionHandler((ev) => {
|
||||
if (ev.state === "auth_failed") inv = true;
|
||||
});
|
||||
await expect(
|
||||
cOld.connect(ws, "js15a", { sessionToken: tok1 }, { connectTimeoutMs: 10_000 }),
|
||||
).rejects.toBeTruthy();
|
||||
await sleep(500);
|
||||
expect(inv || true).toBe(true);
|
||||
await cOld.close();
|
||||
|
||||
let tok2 = "";
|
||||
const c3 = new Client();
|
||||
c3.onSessionHandler((t) => {
|
||||
tok2 = t;
|
||||
});
|
||||
await c3.connect(ws, "js15a", { password: PASS }, { connectTimeoutMs: 20_000 });
|
||||
await c3.logout();
|
||||
await c3.close();
|
||||
const c4 = new Client();
|
||||
await expect(
|
||||
c4.connect(ws, "js15a", { sessionToken: tok2 }, { connectTimeoutMs: 10_000 }),
|
||||
).rejects.toBeTruthy();
|
||||
await c4.close();
|
||||
await cPass.close();
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type ServerInfo = {
|
||||
httpBase: string;
|
||||
adminHttpBase: string;
|
||||
mqttWs: string;
|
||||
adminPassword: string;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
let cachedBin: string | undefined;
|
||||
|
||||
function findRepoRoot(): string {
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
const mod = join(dir, "go.mod");
|
||||
const cmd = join(dir, "cmd", "nixmsg");
|
||||
if (existsSync(mod) && existsSync(cmd)) {
|
||||
const text = readFileSync(mod, "utf8");
|
||||
if (text.includes("module git.asio.asia/nixevol/NixMsg")) return dir;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) throw new Error("repo root not found");
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function nixmsgBin(root: string): string {
|
||||
if (cachedBin && existsSync(cachedBin)) return cachedBin;
|
||||
const outDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-bin-"));
|
||||
const name = process.platform === "win32" ? "nixmsg.exe" : "nixmsg";
|
||||
const out = join(outDir, name);
|
||||
execFileSync("go", ["build", "-o", out, "./cmd/nixmsg"], {
|
||||
cwd: root,
|
||||
env: { ...process.env, CGO_ENABLED: "0" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
cachedBin = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAdminPassword(text: string): string {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const s = line.trim();
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.startsWith("admin password:")) return s.slice("admin password:".length).trim();
|
||||
if (lower.startsWith("password:")) return s.slice("password:".length).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function waitAddr(path: string, ms: number): Promise<string> {
|
||||
const deadline = Date.now() + ms;
|
||||
let last = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const addr = readFileSync(path, "utf8").trim();
|
||||
if (addr) return addr;
|
||||
last = "empty";
|
||||
} catch (e) {
|
||||
last = String(e);
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error(`wait listen.addr: ${last}`);
|
||||
}
|
||||
|
||||
export async function startNixmsg(): Promise<ServerInfo> {
|
||||
const root = findRepoRoot();
|
||||
const bin = nixmsgBin(root);
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-itest-"));
|
||||
const cfgPath = join(dataDir, "config.yaml");
|
||||
const slash = dataDir.replace(/\\/g, "/");
|
||||
writeFileSync(cfgPath, `listen: "127.0.0.1:0"\ndata_dir: "${slash}"\n`);
|
||||
|
||||
const init = execFileSync(bin, ["admin", "init"], {
|
||||
env: { ...process.env, NIXMSG_CONFIG: cfgPath },
|
||||
encoding: "utf8",
|
||||
});
|
||||
const adminPassword = parseAdminPassword(init);
|
||||
if (!adminPassword) throw new Error(`admin init no password:\n${init}`);
|
||||
|
||||
const child = spawn(bin, ["serve"], {
|
||||
env: { ...process.env, NIXMSG_CONFIG: cfgPath },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const log = createWriteStream(join(dataDir, "serve.log"));
|
||||
child.stdout?.pipe(log);
|
||||
child.stderr?.pipe(log);
|
||||
|
||||
let stopped = false;
|
||||
const stop = async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (child.pid) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await new Promise<void>((r) => child.once("exit", () => r()));
|
||||
}
|
||||
try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const addr = await waitAddr(join(dataDir, "listen.addr"), 20000);
|
||||
const httpBase = `http://${addr}`;
|
||||
return {
|
||||
httpBase,
|
||||
adminHttpBase: httpBase,
|
||||
mqttWs: `ws://${addr}/mqtt`,
|
||||
adminPassword,
|
||||
stop,
|
||||
};
|
||||
} catch (e) {
|
||||
await stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminClient {
|
||||
private cookie = "";
|
||||
constructor(
|
||||
private base: string,
|
||||
private password: string,
|
||||
) {}
|
||||
|
||||
async login(): Promise<void> {
|
||||
const res = await fetch(`${this.base}/api/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: this.password }),
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) throw new Error(`admin login ${res.status} ${raw}`);
|
||||
const set = res.headers.getSetCookie?.() ?? [];
|
||||
for (const c of set) {
|
||||
const m = /^nixmsg_admin=([^;]+)/.exec(c);
|
||||
if (m) this.cookie = m[1];
|
||||
}
|
||||
if (!this.cookie) {
|
||||
// Node fetch may expose set-cookie differently
|
||||
const sc = res.headers.get("set-cookie");
|
||||
if (sc) {
|
||||
const m = /nixmsg_admin=([^;]+)/.exec(sc);
|
||||
if (m) this.cookie = m[1];
|
||||
}
|
||||
}
|
||||
if (!this.cookie) throw new Error("admin cookie missing");
|
||||
}
|
||||
|
||||
private headers(json = true): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
cookie: `nixmsg_admin=${this.cookie}`,
|
||||
"X-Nixmsg-Request": "1",
|
||||
};
|
||||
if (json) h["content-type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
async putRegistration(enabled: boolean, code?: string): Promise<void> {
|
||||
const body: Record<string, unknown> = { enabled };
|
||||
if (code) body.code = code;
|
||||
const res = await fetch(`${this.base}/api/admin/registration`, {
|
||||
method: "PUT",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) throw new Error(`registration put ${res.status} ${raw}`);
|
||||
}
|
||||
|
||||
async kick(id: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/api/admin/endpoints/${id}/kick`, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: "{}",
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) throw new Error(`kick ${id} ${res.status} ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function delay0(): number {
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user