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 = {}) { 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((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((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); });