arblant
panel-gateway / test / e2e.mjs
e2e.mjs156 lines · 7.0 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
/* E2E: runs the REAL src/gateway.js in Node.
   Shims: WebSocketPair (linked in-process pair), ctx.waitUntil, DO binding.
   Backend: REAL WebSocket echo server (test/echo-server.mjs) over TCP —
   gateway's outbound `new WebSocket()` is Node's real client.
   Client: synthetic VLESS client speaking through the shimmed pair.

   Run: node test/e2e.mjs
*/
import assert from 'node:assert/strict';

// ---------- WebSocketPair shim (must be installed before importing gateway) ----------
class FakeEndpoint extends EventTarget {
  constructor() { super(); this._peer = null; this.readyState = 1; }
  accept() {}
  send(data) {
    const buf = data instanceof ArrayBuffer ? data.slice(0)
      : ArrayBuffer.isView(data) ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
      : new TextEncoder().encode(String(data)).buffer;
    queueMicrotask(() => this._peer.dispatchEvent(Object.assign(new Event('message'), { data: buf })));
  }
  close(code = 1000, reason = '') {
    const mk = () => Object.assign(new Event('close'), { code, reason });
    queueMicrotask(() => { this._peer.dispatchEvent(mk()); this.dispatchEvent(mk()); });
  }
}
globalThis.WebSocketPair = class {
  constructor() {
    const a = new FakeEndpoint(), b = new FakeEndpoint();
    a._peer = b; b._peer = a;
    this[0] = a; this[1] = b; // [client, server] like the real API
    globalThis.__lastPair = this;
  }
};

const { default: gateway } = await import('../src/gateway.js');
const { Accounting } = await import('../src/accounting.js');
const { buildVlessHeader, uuidToBytes, newUuid } = await import('../src/vless.js');
const { makeSqliteState } = await import('./sqlite-adapter.mjs');
const { startEchoServer } = await import('./echo-server.mjs');

const ECHO_PORT = 18765;
const BACKEND_UUID = newUuid();
const ADMIN = 'test-secret';
const accounting = new Accounting(makeSqliteState(), {});
const fakeStub = { fetch: (url, init) => accounting.fetch(new Request(url, init)) };
const env = {
  BACKEND_WS_URL: `ws://127.0.0.1:${ECHO_PORT}/`,
  BACKEND_UUID, ADMIN_SECRET: ADMIN, FLUSH_MS: '1000',
  ACCOUNTING: { idFromName: () => 'main', get: () => fakeStub },
};
const ctx = { waitUntil(p) { p.catch(() => {}); } };
const aheaders = { Authorization: 'Bearer ' + ADMIN, 'Content-Type': 'application/json' };

const results = [];
function check(name, cond, extra = '') {
  results.push({ name, ok: !!cond, extra });
  console.log((cond ? '✔' : '✘') + ' ' + name + (extra ? ' — ' + extra : ''));
}

async function admin(method, path, body) {
  const r = await gateway.fetch(new Request('http://gw' + path, {
    method, headers: aheaders, body: body ? JSON.stringify(body) : undefined,
  }), env, ctx);
  return { status: r.status, json: await r.json() };
}

// open a "client connection": returns { client, messages, closes }
async function openConn() {
  let res;
  try {
    res = await gateway.fetch(new Request('http://gw/', { headers: { Upgrade: 'websocket' } }), env, ctx);
  } catch (e) {
    // Node's undici Response rejects status 101 (real Workers allow it).
    // The pair + listeners are already set up before the Response is built.
    if (!/init\["status"\]/.test(e.message)) throw e;
    res = { status: 101 };
  }
  assert.equal(res.status, 101);
  const client = globalThis.__lastPair[0];
  const st = { messages: [], closes: [] };
  client.addEventListener('message', (e) => st.messages.push(new Uint8Array(e.data)));
  client.addEventListener('close', (e) => st.closes.push({ code: e.code, reason: e.reason }));
  return { client, st };
}
const wait = (ms) => new Promise(r => setTimeout(r, ms));
async function waitFor(cond, ms, label) {
  const t0 = Date.now();
  while (Date.now() - t0 < ms) { if (await cond()) return true; await wait(100); }
  throw new Error('timeout waiting for: ' + label);
}

// ---------- run ----------
const echoEvents = [];
const echoSrv = await startEchoServer(ECHO_PORT, uuidToBytes(BACKEND_UUID), (e) => echoEvents.push(e));

// 1. create user via admin
const cu = await admin('POST', '/admin/users', { name: 'e2e-user', quotaGB: 10, expiryDays: 30 });
check('admin create user', cu.status === 200 && cu.json.uuid && cu.json.subLink.includes(cu.json.uuid), cu.json.subLink);
const userUuid = cu.json.uuid;

// 2. connect + pipe data (wait for auth echo first: message boundaries aren't preserved)
const { client, st } = await openConn();
const hdr = buildVlessHeader(userUuid, { port: 80, addr: [9, 9, 9, 9] });
const p1 = new Uint8Array(1000).fill(0x41), p2 = new Uint8Array(2000).fill(0x42);
client.send(hdr);
await waitFor(() => st.messages.length >= 1, 5000, 'auth echo');
client.send(p1);
await waitFor(() => st.messages.length >= 2, 5000, 'p1 echo');
client.send(p2);
await waitFor(() => st.messages.length >= 3, 5000, 'p2 echo');
const echoed = st.messages.reduce((a, m) => a + m.length, 0);
check('echo roundtrip', echoed === hdr.length + 3000, `echoed=${echoed}`);
check('backend saw rewritten BACKEND_UUID', echoEvents.some(e => e.type === 'header' && e.ok),
  JSON.stringify(echoEvents));

// 3. byte accounting (flush every 1s in test env)
await wait(1500);
const lu = await admin('GET', '/admin/users');
const row = lu.json.find(u => u.uuid === userUuid);
const expected = hdr.length + 3000;
check('accounting up bytes exact', row.usedUp === expected, `usedUp=${row.usedUp} expected=${expected}`);
check('accounting down bytes exact', row.usedDown === expected, `usedDown=${row.usedDown}`);

// 4. unknown UUID rejected
const { client: c2, st: st2 } = await openConn();
c2.send(buildVlessHeader(newUuid()));
await waitFor(() => st2.closes.length > 0, 3000, 'reject close');
check('unknown uuid -> 4403', st2.closes[0]?.code === 4403, `code=${st2.closes[0]?.code} reason=${st2.closes[0]?.reason}`);

// 5. bad header rejected
const { client: c3, st: st3 } = await openConn();
const bad = buildVlessHeader(userUuid); bad[0] = 0x01;
c3.send(bad);
await waitFor(() => st3.closes.length > 0, 3000, 'bad header close');
check('bad version -> 4400', st3.closes[0]?.code === 4400, `code=${st3.closes[0]?.code}`);

// 6. quota cutoff mid-connection
const q = await admin('POST', '/admin/users', { name: 'quota-user', quotaGB: 0.001 }); // ~1MB
const quuid = q.json.uuid;
const { client: c4, st: st4 } = await openConn();
c4.send(buildVlessHeader(quuid));
await waitFor(() => st4.messages.length >= 1, 5000, 'quota auth echo');
c4.send(new Uint8Array(1_300_000).fill(0x43)); // 1.3MB > 1MB quota
await waitFor(() => st4.closes.length > 0, 8000, 'quota close');
check('quota exceeded -> 4403 close', st4.closes[0]?.code === 4403, `code=${st4.closes[0]?.code} reason=${st4.closes[0]?.reason}`);
const vq = await accounting.validate({ uuid: quuid });
check('quota user now blocked', vq.reason === 'quota-exceeded');

// 7. admin auth
const unauth = await gateway.fetch(new Request('http://gw/admin/users'), env, ctx);
check('admin without token -> 401', unauth.status === 401);

echoSrv.close();
const failed = results.filter(r => !r.ok);
console.log(`\nE2E: ${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length ? 1 : 0);