import assert from 'node:assert/strict';
import test from 'node:test';
import {createServer} from 'node:https';
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {once} from 'node:events';
import WS, {WebSocketServer} from 'ws';
import {Client, installBoundedTransport, validatedSasl, importMessage, validateMessage, backend} from '../dist/browser.mjs';
import {vectors as rfc} from './fixtures.mjs';
const sha512 = JSON.parse(readFileSync(new URL('./scramp-vectors.json', import.meta.url)));
const vectors = [...rfc, sha512[0]];
const enc = s => Buffer.from(s).toString('base64');
const sasl = (type, data='') => `<${type} xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${enc(data)}${type}>`;
const waitFor = async fn => {
for (let i=0;i<300;i++) { if (fn()) return; await new Promise(r=>setTimeout(r,10)); }
throw new Error('Fixture timeout');
};
const dir = mkdtempSync(join(tmpdir(), 'luma-wss-'));
// Ephemeral synthetic localhost CA/server certificate; never committed.
execFileSync('openssl', ['req','-x509','-newkey','rsa:2048','-nodes','-keyout',join(dir,'key.pem'),
'-out',join(dir,'cert.pem'),'-days','1','-subj','/CN=localhost',
'-addext','subjectAltName=DNS:localhost,IP:127.0.0.1'], {stdio:'ignore'});
const cert = readFileSync(join(dir,'cert.pem'));
const key = readFileSync(join(dir,'key.pem'));
test.after(()=>rmSync(dir,{recursive:true,force:true}));
async function scenario(t, vector, offers, mode='valid') {
const server=createServer({key,cert});
const wss=new WebSocketServer({server,handleProtocols: protocols=>protocols.has('xmpp')?'xmpp':false});
const seen={opens:0,success:0,failed:0,rejected:[],policies:[],peerErrors:[]};
const client=new Client({jid:'user@example.invalid',server:'example.invalid',autoReconnect:false,
credentials:{username:vector.username,password:vector.password,clientNonce:vector.nonce},
transports:{'bounded-websocket':'wss://127.0.0.1'},transportPreferenceOrder:['bounded-websocket']});
// Explicit CA trust for this fixture only; hostname/TLS validation stays enabled.
class TrustedSocket extends WS {constructor(url,protocol){super(url,protocol,{ca:cert,rejectUnauthorized:true});}}
installBoundedTransport(client,TrustedSocket);
client.use(validatedSasl);
client.on('auth:success',()=>seen.success++);
client.on('auth:failed',()=>seen.failed++);
client.on('ingress:rejected',code=>seen.rejected.push(code));
client.on('sasl:policy',policy=>seen.policies.push(policy));
wss.on('connection',(socket,req)=>{
seen.protocol=req.headers['sec-websocket-protocol'];
seen.tls=socket._socket.encrypted;
socket.on('message', (bytes,isBinary)=>{
try {
assert.equal(isBinary,false);
const xml=bytes.toString();
if (xml.startsWith('');
if (seen.opens===1) {
if (mode==='oversize') return socket.send(''+ 'x'.repeat(4096)+'');
if (mode==='split') return socket.send('');
socket.send(''+offers.map(x=>`${x}`).join('')+'');
}
} else if (xml.startsWith('([^<]+)([^<]+){await client.disconnect(); for(const socket of wss.clients)socket.terminate(); await new Promise(r=>wss.close(r)); await new Promise(r=>server.close(r));});
await client.connect();
await waitFor(()=> mode==='valid'? seen.opens===2 : seen.failed||seen.rejected.length||seen.peerErrors.length);
assert.deepEqual(seen.peerErrors,[]);
assert.equal(seen.protocol,'xmpp'); assert.equal(seen.tls,true);
if(mode==='valid') {
assert.equal(seen.success,1); assert.equal(seen.failed,0); assert.equal(seen.mechanism,vector.mechanism);
assert.equal(seen.proof,vector.proof); assert.equal(seen.opens,2);
assert.deepEqual(seen.policies,[{selected:vector.mechanism,channelBinding:'unavailable',fallback:'disabled'}]);
} else {assert.equal(seen.success,0);assert.equal(seen.opens,1);}
return seen;
}
test('browser bundle uses browser backend with TLS validation enabled',()=>{assert.equal(backend,'browser');assert.notEqual(process.env.NODE_TLS_REJECT_UNAUTHORIZED,'0');});
test('public transport rejects plaintext endpoint before socket creation',async()=>{
const client=new Client({transports:{'bounded-websocket':'ws://127.0.0.1'},transportPreferenceOrder:['bounded-websocket']});
let sockets=0; installBoundedTransport(client,class{constructor(){sockets++;}});
await assert.rejects(client.connect(),/WSS required/);assert.equal(sockets,0);
});
for(const vector of vectors) {
const offers=['PLAIN','SCRAM-SHA-512-PLUS','SCRAM-SHA-256-PLUS','SCRAM-SHA-1',
...(vector.mechanism!=='SCRAM-SHA-1'?['SCRAM-SHA-256']:[]),
...(vector.mechanism==='SCRAM-SHA-512'?['SCRAM-SHA-512']:[])];
test(`trusted WSS strongest compatible ${vector.mechanism}, verified restart`,t=>scenario(t,vector,offers));
for(const mode of ['forged','premature'])test(`WSS ${vector.mechanism} rejects ${mode}`,t=>scenario(t,vector,offers,mode));
}
for(const mode of ['oversize','split']) test(`WSS bounded ingress rejects ${mode}`,async t=>{
const seen=await scenario(t,vectors[2],['SCRAM-SHA-512'],mode);assert.equal(seen.rejected.length,1);
});
for(const offers of [['SCRAM-SHA-512-PLUS'],['PLAIN'],[]]) test(`WSS refuses unsupported offer ${offers}`,async t=>{
const seen=await scenario(t,vectors[2],offers,'unsupported'); assert.equal(seen.failed,1); assert.equal(seen.mechanism,undefined);
});
test('default native WebSocket rejects untrusted WSS certificate',async t=>{
const server=createServer({key,cert}); const wss=new WebSocketServer({server});
server.listen(0,'127.0.0.1');await once(server,'listening');
t.after(async()=>{wss.close();await new Promise(r=>server.close(r));});
const socket=new globalThis.WebSocket(`wss://127.0.0.1:${server.address().port}`,'xmpp');
let opened=false;socket.onopen=()=>{opened=true;socket.close();};
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{socket.close();reject(new Error('TLS timeout'));},3000);socket.onerror=()=>{clearTimeout(timer);resolve();};});
assert.equal(opened,false);
});
test('XML resource and standalone boundaries compose before registry import',()=>{
const client=new Client();let imports=0; const registry={getImportKey:()=>{imports++;return 'message';},import:()=>{imports++;return {};}};
for(const data of ['', '','',''+ ''.repeat(8)+''.repeat(8)+'',new Uint8Array(1)]) {
assert.throws(()=>importMessage(data,registry,{}));assert.equal(imports,0);
}
assert.doesNotThrow(()=>validateMessage('safe & synthetic'));
assert.equal(importMessage('synthetic',client.stanzas,{}).stanza.body,'synthetic');
});