/** Run with node scripts/js_client_test.mjs /path/to/ratdb (Node 20+). */ import {test} from "node:test"; import assert from "node:assert/strict"; import {once} from "node:events"; import {mkdtemp, rm} from "node:fs/promises"; import {randomBytes} from "node:crypto"; import {tmpdir} from "node:os"; import {join, resolve} from "node:path"; import {spawn, execFileSync} from "node:child_process"; import {createServer} from "node:net"; import {createInterface} from "node:readline"; import {Client, ProtocolError, RatDBError} from "../clients/javascript/ratdb.mjs"; if (!process.argv[2]) throw new Error("provide the built RatDB binary path"); const binary = resolve(process.argv[2]); const password = randomBytes(24).toString("hex"); const failure = code => err => err instanceof RatDBError && err.code === code; async function start(path) { const child = spawn(binary, ["serve", path, "--bind", "127.0.0.1:0"], {windowsHide:true, stdio:["ignore","pipe","inherit"]}); const lines = createInterface({input:child.stdout}); const exit = once(child,"exit"); const timeout = setTimeout(()=>child.kill(),15_000); try { const line = await Promise.race([ once(lines,"line").then(([v])=>v), exit.then(()=>{throw Error("server exited during startup")}) ]); const match = /listening on 127\.0\.0\.1:(\d+)/.exec(line); assert.ok(match,line); return {port:Number(match[1]),stop:async()=>{child.kill();await exit}}; } catch (err) { child.kill();await exit;throw err; } finally { clearTimeout(timeout);lines.close(); } } test("real authentication, CRUD, conditional writes, isolation, paging and restart",async()=>{ const dir=await mkdtemp(join(tmpdir(),"ratdb-js-")); const path=join(dir,"instance.ratdb"); let server,db; try { for(const args of [["init",path],["setup",path,"admin"]]) { execFileSync(binary,args,{env:{...process.env,RATDB_PASSWORD:password},timeout:20_000,windowsHide:true}); } server=await start(path); await assert.rejects(Client.connect({password:"wrong",port:server.port}),failure("authentication")); db=await Client.connect({password,port:server.port}); await assert.rejects(db.selectDen("cars"),/select a warren/); await db.selectWarren("garage"); await db.selectDen("cars"); await db.insert("01",{make:"Ford",note:"caf\u00e9"}); await assert.rejects(db.insert("01",{}),failure("operation_failed")); await db.update("01",{year:2022}); const pending=db.put("02",{make:"Ford"}); await assert.rejects(db.put("wrong",{}),/previous request/); await pending; const {generation}=await db.getVersioned("01"); const changes=[{op:"put",warren:"garage",den:"notes",id:"a",document:{owner:"01"}}]; await db.transaction(generation,changes); await assert.rejects(db.transaction(generation,changes),failure("generation_conflict")); let nested; await db.put("hook",{toJSON(){nested=db.get("01").catch(e=>e);return {value:1}}}); assert.match((await nested).message,/previous request/); await db.delete("hook"); assert.deepEqual((await db.find("make","Ford")).documents.map(d=>d.id),["01","02"]); const page=await db.page(1); assert.equal(page.documents[0].id,"01"); assert.equal((await db.page(1,page.next_after)).documents[0].id,"02"); await db.selectWarren("archive");await db.selectDen("cars"); assert.equal(await db.get("01"),null); await db.put("01",{make:"Honda"});await db.delete("01"); assert.equal((await db.scan()).documents.length,0); await assert.rejects(db.put("large",{name:"x".repeat(1048576)}),RangeError); await assert.rejects(db.put("non-json",{ignored:undefined}),TypeError); await assert.rejects(db.put("number",{value:2**53}),RangeError); db.close(); await server.stop(); server=await start(path); db=await Client.connect({password,port:server.port}); await db.selectWarren("garage");await db.selectDen("cars"); assert.deepEqual(await db.get("01"),{make:"Ford",note:"caf\u00e9",id:"01",year:2022}); assert.equal(await db.get("wrong"),null); await db.selectDen("notes");assert.equal((await db.get("a")).owner,"01"); } finally { db?.close();if (server) await server.stop(); await rm(dir,{recursive:true,force:true}); } }); function encoded(data) { const payload=Buffer.isBuffer(data)?data:Buffer.from(JSON.stringify(data)); const head=Buffer.alloc(4);head.writeUInt32BE(payload.length); return Buffer.concat([head,payload]); } const success=data=>encoded({version:1,ok:true,data,error:null}); async function mock(run, action) { const peers=new Set(); const server=createServer(s=>{ peers.add(s);s.on("error",()=>{});s.on("close",()=>peers.delete(s)); let bytes=Buffer.alloc(0), requests=0; s.on("data",chunk=>{ bytes=Buffer.concat([bytes,chunk]); while(bytes.length>=4 && bytes.length>=4+bytes.readUInt32BE()) { const n=bytes.readUInt32BE();bytes=bytes.subarray(4+n); requests++; if(requests===1) s.write(success({authenticated:true})); else run(s,requests); } }); }); await new Promise((ok,fail)=>{server.once("error",fail);server.listen(0,"127.0.0.1",ok)}); let db; try { db=await Client.connect({password,port:server.address().port,timeoutMs:250}); await action(db); } finally { db?.close();for(const s of peers)s.destroy(); await new Promise(ok=>server.close(ok)); } } test("fragmented header and body",async()=>{ await mock(s=>{ const frame=success({id:"x",text:"\u754c"}); s.write(frame.subarray(0,2)); setTimeout(()=>s.write(frame.subarray(2,9)),5); setTimeout(()=>s.write(frame.subarray(9)),15); },async db=>assert.deepEqual(await db.get("x"),{id:"x",text:"\u754c"})); }); const cases=[ ["empty frame",s=>s.end(Buffer.alloc(4))], ["oversize announced length",s=>{const h=Buffer.alloc(4);h.writeUInt32BE(1_048_577);s.write(h)}], ["invalid JSON",s=>s.write(encoded(Buffer.from("{")))], ["invalid UTF-8",s=>s.write(encoded(Buffer.from([0xff])))], ["wrong envelope",s=>s.write(encoded({version:2,ok:true,data:null,error:null}))], ["unsafe numeric value",s=>s.write(success({n:2**53}))], ["inconsistent success",s=>s.write(encoded({version:1,ok:true,data:null,error:{}}))], ["trailing frame",s=>s.write(Buffer.concat([success(null),success(null)]))], ["disconnect mid-frame",s=>s.end(success("lost").subarray(0,10))], ["whole-frame deadline",s=>{ const frame=success({id:"x"}); s.write(frame.subarray(0,1)); setTimeout(()=>s.write(frame.subarray(1,6)),175); setTimeout(()=>s.write(frame.subarray(6)),350); }], ]; for(const [name,run] of cases) test(name,()=>mock(run,async db=>{ await assert.rejects(db.get("x"),ProtocolError); await assert.rejects(db.get("x"),/session is closed/); })); test("remote and DNS endpoints rejected before connection",async()=>{ for(const host of ["192.0.2.1","::ffff:192.0.2.1","localhost","127.0.0.1.example"]) { await assert.rejects(Client.connect({host,password}),/loopback/); } });