Files
NixMsg/sdk/js/test/checklist.test.ts

487 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});