/** * Tests for electron/mobile-lan-proxy.ts — the "Telefon bağlantısı" LAN proxy, * pairing session and controller (contract: aiturk-mobil-eslestirme-spec.md §5). * * Real TLS sockets on 127.0.0.1 only, against a fake backend that mimics the * dashboard's loopback rules (Host check, session token, token in the root * page). No real backend, gateway, firewall or LAN interface is touched. */ import crypto from 'node:crypto' import { EventEmitter } from 'node:events' import http from 'node:http' import https from 'node:https' import net from 'node:net' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { WebSocket, WebSocketServer } from 'ws' import { createSelfSignedCertificate } from './mobile-lan-cert' import { buildPairQrPayload, cancelPairOffer, createLanProxy, createMobileLink, createWindowsFirewall, firewallCommand, firewallQueryCommand, formatPairCode, generatePairCode, isPhoneForbidden, isPrivateIpv4, MOBILE_LAN_DIRECT_HOST, MOBILE_LOOPBACK_HOST, MobileLinkError, type MobileLinkEvent, normalizePairCode, PAIR_CODE_ALPHABET, PAIR_OFFER_MAX_TRIES, PAIR_OFFER_RETRY_DELAYS_MS, pairLookup, parseFirewallRuleOutput, parseMobileLinkStored, publishPairOffer, selectLanAddresses } from './mobile-lan-proxy' const SESSION = crypto.randomBytes(32).toString('base64url') const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1']) const hostOnly = (value: unknown) => String(value || '') .replace(/:\d+$/, '') .toLowerCase() const seen: { path: string; host: string; raw: string; method: string }[] = [] const wrongCode = (code: string) => (code === 'ZZZZZZZZ' ? 'YYYYYYYY' : 'ZZZZZZZZ') // ── fake backend: host check + session auth + token in the root page ──────── let backend: http.Server let backendPort = 0 let wss: WebSocketServer let resolveCalls = 0 const resolveBackend = async () => { resolveCalls++ return { baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION } } beforeAll(async () => { backend = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://x') const send = (status: number, body: string, type = 'application/json') => { res.writeHead(status, { 'content-type': type, 'set-cookie': 'x=1' }) res.end(body) } if (!LOOPBACK.has(hostOnly(req.headers.host))) { return send(400, '{"detail":"Invalid Host header"}') } if (url.pathname === '/') { return send(200, ``, 'text/html') } if (url.pathname.startsWith('/api/') && url.pathname !== '/api/status') { const ok = req.headers['x-hermes-session-token'] === SESSION || req.headers.authorization === `Bearer ${SESSION}` || (url.pathname === '/api/files/download' && url.searchParams.get('token') === SESSION) if (!ok) { return send(401, '{"detail":"Unauthorized"}') } } seen.push({ path: req.url ?? '', host: String(req.headers.host), method: String(req.method), raw: JSON.stringify(req.headers) + req.url }) send(200, JSON.stringify({ ok: true, path: url.pathname })) }) wss = new WebSocketServer({ noServer: true }) backend.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://x') const origin = req.headers.origin const allowed = url.searchParams.get('token') === SESSION && LOOPBACK.has(hostOnly(req.headers.host)) && !req.headers.cookie && (!origin || LOOPBACK.has(new URL(origin).hostname)) // The real backend also accepts /api/pty with a valid token: the block must come from the proxy. if (!allowed || !['/api/ws', '/api/pty', '/api/console', '/api/plugins/kanban/events'].includes(url.pathname)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n') return } wss.handleUpgrade(req, socket, head, ws => ws.on('message', data => { const frame = JSON.parse(String(data)) if (frame.method === 'gateway.ping') { ws.send(JSON.stringify({ jsonrpc: '2.0', result: { ok: true }, id: frame.id })) } }) ) }) await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve)) backendPort = (backend.address() as net.AddressInfo).port }) afterAll(async () => { wss.close() backend.closeAllConnections() await new Promise(resolve => backend.close(resolve)) }) // ── client helpers (what the phone does: trust ONLY the pinned certificate) ─ interface CallOptions { token?: string headerToken?: string body?: unknown rawBody?: string ca?: string headers?: Record } function call(port: number, method: string, path: string, options: CallOptions = {}) { return new Promise<{ status: number; text: string; headers: http.IncomingHttpHeaders; json: any }>( (resolve, reject) => { const payload = options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)) const req = https.request( { host: '127.0.0.1', port, method, path, agent: false, ...(options.ca ? { ca: options.ca } : {}), checkServerIdentity: () => undefined, headers: { ...(options.token ? { authorization: `Bearer ${options.token}` } : {}), ...(options.headerToken ? { 'x-hermes-session-token': options.headerToken } : {}), ...(payload ? { 'content-type': 'application/json' } : {}), origin: 'http://evil.example', cookie: 'session=evil', ...options.headers } }, res => { let text = '' res.on('data', chunk => (text += chunk)) res.on('end', () => { let json: any = null try { json = JSON.parse(text) } catch { json = null } resolve({ status: res.statusCode ?? 0, text, headers: res.headers, json }) }) } ) req.on('error', reject) req.end(payload) } ) } function wsOpen(url: string, ca: string, extra: Record = {}) { return new Promise<{ open: boolean; ws?: WebSocket; status?: number; error?: string }>(resolve => { const ws = new WebSocket(url, { ca, checkServerIdentity: () => undefined, ...extra } as any) ws.once('open', () => resolve({ ws, open: true })) ws.once('unexpected-response', (_req, res) => { resolve({ open: false, status: res.statusCode }) res.resume() }) ws.once('error', error => resolve({ open: false, error: error.message })) }) } const connectOutcome = (port: number, host = '127.0.0.1') => new Promise(resolve => { const socket = net.connect(port, host) socket.once('connect', () => { socket.destroy() resolve('open') }) socket.once('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) }) async function until(check: () => boolean, ms = 3000) { const end = Date.now() + ms while (!check()) { if (Date.now() > end) { throw new Error('condition not reached in time') } await new Promise(resolve => setTimeout(resolve, 10)) } } // ── proxy fixture ─────────────────────────────────────────────────────────── async function startProxy(over: Record = {}) { const identity = createSelfSignedCertificate() const clock = { now: 1_800_000_000_000 } const logs: string[] = [] const saved: any[] = [] const ended: string[] = [] const proxy = createLanProxy({ tls: identity, resolveBackend, version: '1.0.0-test', pcName: 'TEST-PC', now: () => clock.now, log: line => logs.push(line), onDevicesChanged: devices => { saved.push(structuredClone(devices)) }, onPairingEnded: reason => { ended.push(reason) }, ...over } as any) const port = await proxy.listen(0, '127.0.0.1') const pair = async (name = 'Pixel 9') => { const { code } = proxy.beginPairing() const r = await call(port, 'POST', '/aiturk/pair/claim', { ca: identity.cert, body: { code, device_name: name } }) expect(r.status).toBe(200) return { code, token: r.json.token as string, deviceId: r.json.device_id as string, response: r } } return { proxy, port, identity, clock, logs, saved, ended, pair, ca: identity.cert } } describe('LAN proxy: pairing (hello / claim)', () => { it('hello is unauthenticated and reports whether pairing is open', async () => { const t = await startProxy() expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json).toEqual({ app: 'aiturk-ide', version: '1.0.0-test', pairing: false }) t.proxy.beginPairing() expect((await call(t.port, 'GET', '/aiturk/pair/hello', { ca: t.ca })).json.pairing).toBe(true) await t.proxy.stop() }) it('claim is 410 pairing_closed while pairing is not open', async () => { const t = await startProxy() const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: 'AAAAAAAA', device_name: 'x' } }) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) await t.proxy.stop() }) it('four wrong codes are 401 wrong_code; the right code (lower case, dashed) then yields a device token', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) expect([r.status, r.json]).toEqual([401, { error: 'wrong_code' }]) } const typed = `${code.slice(0, 4).toLowerCase()}-${code.slice(4).toLowerCase()} ` const ok = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: typed, device_name: ' Pixel\u0000 9 ' } }) expect(ok.status).toBe(200) expect(Object.keys(ok.json).sort()).toEqual(['device_id', 'pc_name', 'token', 'version']) expect(ok.json.token).toMatch(/^amd_[A-Za-z0-9_-]{43}$/) expect(ok.json.device_id).toMatch(/^[0-9a-f-]{36}$/) expect(ok.json.pc_name).toBe('TEST-PC') expect(ok.json.version).toBe('1.0.0-test') expect(ok.headers['cache-control']).toBe('no-store') expect(t.proxy.devices()[0].name).toBe('Pixel 9') // Only the digest is handed to the store. expect(t.saved.at(-1)[0].tokenHash).toBe(crypto.createHash('sha256').update(ok.json.token).digest('hex')) expect(JSON.stringify(t.saved)).not.toContain('amd_') expect(t.ended).toEqual(['claimed']) await t.proxy.stop() }) it('the same code is 410 on second use (consumed before the token is minted)', async () => { const t = await startProxy() const { code } = await t.pair() const again = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([again.status, again.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.proxy.devices()).toHaveLength(1) await t.proxy.stop() }) it('the fifth wrong attempt locks pairing; the right code is then 410 too', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (let i = 0; i < 4; i++) { expect( ( await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) ).status ).toBe(401) } const fifth = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code: wrongCode(code), device_name: 'x' } }) expect([fifth.status, fifth.json]).toEqual([410, { error: 'pairing_locked' }]) const right = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([right.status, right.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.proxy.devices()).toHaveLength(0) expect(t.ended).toEqual(['locked']) await t.proxy.stop() }) it('an expired code is 410 pairing_closed', async () => { const t = await startProxy() const { code, expiresAt } = t.proxy.beginPairing() expect(expiresAt - t.clock.now).toBe(5 * 60_000) t.clock.now += 5 * 60_000 const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) expect([r.status, r.json]).toEqual([410, { error: 'pairing_closed' }]) expect(t.ended).toEqual(['expired']) await t.proxy.stop() }) it('malformed bodies are 400 invalid_request and do not burn an attempt; > 4 KiB is 413', async () => { const t = await startProxy() const { code } = t.proxy.beginPairing() for (const rawBody of [ '{not json', '[]', '{"code":12345678,"device_name":"x"}', `{"code":"${code}"}`, `{"code":"${code}","device_name":""}`, JSON.stringify({ code, device_name: 'n'.repeat(61) }) ]) { const r = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody }) expect([r.status, r.json]).toEqual([400, { error: 'invalid_request' }]) } const big = await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, rawBody: JSON.stringify({ code, device_name: 'x', pad: 'p'.repeat(5000) }) }) expect(big.status).toBe(413) expect( (await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } })).status ).toBe(200) await t.proxy.stop() }) }) describe('LAN proxy: REST', () => { it('root and everything outside /api/ is 404 and never carries the backend session token', async () => { const t = await startProxy() const { token } = await t.pair() for (const path of ['/', '/index.html', '/api', '/assets/app.js', '/aiturk/pair/other', '/?token=' + token]) { const r = await call(t.port, 'GET', path, { ca: t.ca, token }) expect([path, r.status]).toEqual([path, 404]) expect(r.text).not.toContain(SESSION) expect(r.json).toEqual({ error: 'not_found' }) } await t.proxy.stop() }) it('no token, a wrong token and the backend’s REAL token are all 401 — and never resolve (start) a backend', async () => { const t = await startProxy() await t.pair() resolveCalls = 0 const attempts: [string, CallOptions][] = [ ['/api/sessions', {}], ['/api/status', {}], // public on the backend, still closed here ['/api/sessions', { token: 'amd_wrong' }], ['/api/sessions', { token: `amd_${'A'.repeat(43)}` }], ['/api/sessions', { token: SESSION }], ['/api/sessions', { headerToken: SESSION }], [`/api/files/download?token=${SESSION}`, {}], ['/api/env', { token: SESSION }] ] for (const [path, options] of attempts) { const r = await call(t.port, 'GET', path, { ca: t.ca, ...options }) expect([path, r.status, r.json]).toEqual([path, 401, { error: 'unauthorized' }]) } expect(resolveCalls).toBe(0) await t.proxy.stop() }) it('a valid device token is forwarded with the real session token; the backend never sees the device token, Origin or Cookie', async () => { const t = await startProxy() const { token } = await t.pair() for (const options of [{ token }, { headerToken: token }]) { seen.length = 0 const r = await call(t.port, 'GET', '/api/sessions?limit=20', { ca: t.ca, ...options, headers: { 'x-forwarded-for': '10.0.0.9' } }) expect(r.status).toBe(200) expect(r.json).toEqual({ ok: true, path: '/api/sessions' }) expect(seen).toHaveLength(1) expect(seen[0].host).toBe(`127.0.0.1:${backendPort}`) expect(seen[0].path).toBe('/api/sessions?limit=20') expect(seen[0].raw).toContain(SESSION) expect(seen[0].raw).not.toContain('amd_') expect(seen[0].raw).not.toContain('evil') expect(seen[0].raw).not.toContain('10.0.0.9') expect(r.headers['set-cookie']).toBeUndefined() expect(r.text).not.toContain(SESSION) } const post = await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { title: 'x' } }) expect(post.status).toBe(200) expect(seen.at(-1)?.method).toBe('POST') await t.proxy.stop() }) it('?token= is swapped for the backend token (download link form)', async () => { const t = await startProxy() const { token } = await t.pair() seen.length = 0 const r = await call(t.port, 'GET', `/api/files/download?path=a.png&token=${token}`, { ca: t.ca }) expect(r.status).toBe(200) expect(seen[0].path).toContain(`token=${SESSION}`) expect(seen[0].path).not.toContain('amd_') await t.proxy.stop() }) it('forbidden endpoints are 403 before the backend is touched; allowed media and reads are 200', async () => { const t = await startProxy() const { token } = await t.pair() const forbidden: [string, string][] = [ ['GET', '/api/env'], ['PUT', '/api/env'], ['POST', '/api/env/reveal'], ['GET', '/api/config/raw'], ['PUT', '/api/config/raw'], ['POST', '/api/ops/doctor'], ['GET', '/api/ops/backup/download?archive=x'], ['POST', '/api/hermes/update'], ['GET', '/api/hermes/update/check'], ['GET', '/api/credentials/pool'], ['POST', '/api/credentials/pool'], ['POST', '/api/providers/custom-endpoints'], ['DELETE', '/api/providers/oauth/x'], ['POST', '/api/mcp/servers'], ['POST', '/api/mcp/catalog/install'], ['POST', '/api/webhooks'], ['PUT', '/api/config'], ['POST', '/api/config/model'], ['DELETE', '/api/webhooks/x'], ['POST', '/api/dashboard/agent-plugins/install'], ['DELETE', '/api/dashboard/agent-plugins/x'], ['GET', '/api/files'], ['DELETE', '/api/files?path=x'], ['GET', '/api/files/read?path=x'], ['POST', '/api/files/upload'], ['POST', '/api/files/download'], // encodings the backend would decode back to a forbidden route ['GET', '/api/%65nv'], ['GET', '/api/ENV'], ['GET', '/api/files%2Fread?path=x'], ['GET', '/api/sessions/../env'] ] seen.length = 0 resolveCalls = 0 for (const [method, path] of forbidden) { const r = await call(t.port, method, path, { ca: t.ca, token, ...(method === 'GET' || method === 'DELETE' ? {} : { body: {} }) }) expect([method, path, r.status, r.json]).toEqual([method, path, 403, { error: 'forbidden' }]) } expect(seen).toHaveLength(0) expect(resolveCalls).toBe(0) for (const path of ['/api/%2e%2e%2fapi/env/x%00', '/api//env', '/api/a%5Cb', '/api/x%2525']) { expect([path, (await call(t.port, 'GET', path, { ca: t.ca, token })).status]).toEqual([path, 400]) } const allowed: [string, string][] = [ ['GET', '/api/files/download?path=x'], ['GET', '/api/files/stream?path=x'], ['GET', '/api/providers/custom-endpoints'], ['GET', '/api/mcp/servers'], ['GET', '/api/webhooks'], ['GET', '/api/config'], ['GET', '/api/sessions'] ] for (const [method, path] of allowed) { expect([path, (await call(t.port, method, path, { ca: t.ca, token })).status]).toEqual([path, 200]) } await t.proxy.stop() }) it('routes REST and WebSocket by the phone profile, never the desktop selection', async () => { const profiles: string[] = [] const t = await startProxy({ resolveBackend: async (profile: string) => { profiles.push(profile) return resolveBackend() } }) try { const { token } = await t.pair() for (const query of ['', '?profile=kodlama', '?profile=all']) { expect((await call(t.port, 'GET', `/api/cron/jobs${query}`, { ca: t.ca, token })).status).toBe(200) } const opened = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}&profile=seo`, t.ca) expect(opened.open).toBe(true) opened.ws?.close() expect(profiles).toEqual(['default', 'kodlama', 'default', 'seo']) expect(seen.some(row => row.path === '/api/cron/jobs?profile=all')).toBe(true) } finally { await t.proxy.stop() } }) it('bounds a pending setup/backend resolution for REST and WebSocket and never forwards the late result', async () => { let finish: (value: { baseUrl: string; token: string }) => void = () => {} const pending = new Promise<{ baseUrl: string; token: string }>(resolve => { finish = resolve }) const t = await startProxy({ resolveBackend: () => pending, backendResolveTimeoutMs: 40 }) try { const { token } = await t.pair() const before = seen.length const rest = await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token }) expect([rest.status, rest.json]).toEqual([503, { error: 'backend_unavailable' }]) expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca)).status).toBe(503) finish({ baseUrl: `http://127.0.0.1:${backendPort}`, token: SESSION }) await new Promise(resolve => setTimeout(resolve, 30)) expect(seen.length).toBe(before) expect((await call(t.port, 'GET', '/api/cron/jobs?profile=default', { ca: t.ca, token })).status).toBe(200) } finally { await t.proxy.stop() } }) it('refuses when the backend cannot be resolved locally (remote mode / not loopback) without leaking why', async () => { const remote = await startProxy({ resolveBackend: async () => { throw new Error(`remote gateway secret ${SESSION}`) } }) const a = await remote.pair() const r = await call(remote.port, 'GET', '/api/sessions', { ca: remote.ca, token: a.token }) expect([r.status, r.json]).toEqual([503, { error: 'backend_unavailable' }]) expect((await wsOpen(`wss://127.0.0.1:${remote.port}/api/ws?token=${a.token}`, remote.ca)).status).toBe(503) await remote.proxy.stop() const elsewhere = await startProxy({ resolveBackend: async () => ({ baseUrl: 'https://gateway.example', token: SESSION }) }) const b = await elsewhere.pair() expect((await call(elsewhere.port, 'GET', '/api/sessions', { ca: elsewhere.ca, token: b.token })).status).toBe(503) await elsewhere.proxy.stop() }) }) describe('LAN proxy: WebSocket', () => { it('no token / wrong token / backend token are 401; /api/pty and /api/console are 403 even with a valid token', async () => { const t = await startProxy() const { token } = await t.pair() const base = `wss://127.0.0.1:${t.port}` resolveCalls = 0 expect((await wsOpen(`${base}/api/ws`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/ws?token=amd_wrong`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/ws?token=${SESSION}`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/pty`, t.ca)).status).toBe(401) expect((await wsOpen(`${base}/api/pty?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/console?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/ws/extra?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/api/%70ty?token=${token}`, t.ca)).status).toBe(403) expect((await wsOpen(`${base}/?token=${token}`, t.ca)).status).toBe(404) expect(resolveCalls).toBe(0) await t.proxy.stop() }) it('/api/ws and the kanban events path open with the device token (Origin and Cookie dropped) and carry frames', async () => { const t = await startProxy() const { token, deviceId } = await t.pair() for (const path of ['/api/ws', '/api/plugins/kanban/events']) { const r = await wsOpen(`wss://127.0.0.1:${t.port}${path}?token=${token}`, t.ca, { origin: 'http://evil.example', headers: { cookie: 'session=evil' } }) expect([path, r.open]).toEqual([path, true]) const answer = await new Promise(resolve => { r.ws?.once('message', data => resolve(JSON.parse(String(data)))) r.ws?.send(JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'gateway.ping' })) }) expect(answer).toEqual({ jsonrpc: '2.0', result: { ok: true }, id: 7 }) expect(t.proxy.devices().find(device => device.id === deviceId)?.live).toBe(1) const closed = new Promise(resolve => r.ws?.once('close', resolve)) r.ws?.close() await closed await until(() => t.proxy.devices()[0].live === 0) } await t.proxy.stop() }) it('revoking a device closes its open WebSocket and turns its token 401 (REST and WS); other devices keep working', async () => { const t = await startProxy() const first = await t.pair('Telefon 1') const second = await t.pair('Telefon 2') const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca) const other = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${second.token}`, t.ca) expect([live.open, other.open]).toEqual([true, true]) const closed = new Promise(resolve => live.ws?.once('close', resolve)) expect(await t.proxy.revoke(first.deviceId)).toBe(true) await closed expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: first.token })).status).toBe(401) expect((await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${first.token}`, t.ca)).status).toBe(401) expect(t.proxy.devices().map(device => device.name)).toEqual(['Telefon 2']) expect(t.saved.at(-1)).toHaveLength(1) expect((await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: second.token })).status).toBe(200) expect(other.ws?.readyState).toBe(WebSocket.OPEN) expect(await t.proxy.revoke(first.deviceId)).toBe(false) await t.proxy.stop() }) }) describe('LAN proxy: TLS, listening and logs', () => { it('serves exactly the pinned certificate; a client using the system CAs rejects it; plain HTTP does not work', async () => { const t = await startProxy() const peer = await new Promise((resolve, reject) => { const req = https.request( { host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', rejectUnauthorized: false, agent: false }, res => { resolve( crypto .createHash('sha256') .update((res.socket as any).getPeerCertificate(true).raw) .digest('base64url') ) res.resume() } ) req.on('error', reject) req.end() }) expect(peer).toBe(t.identity.fingerprint) expect(peer).not.toBe(createSelfSignedCertificate().fingerprint) // A phone pinned to ANOTHER certificate must refuse this server. await expect( call(t.port, 'GET', '/aiturk/pair/hello', { ca: createSelfSignedCertificate().cert }) ).rejects.toThrow() const systemCa = await new Promise(resolve => { const req = https.request({ host: '127.0.0.1', port: t.port, path: '/aiturk/pair/hello', agent: false }, () => resolve('accepted') ) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code))) req.end() }) expect(systemCa).toMatch(/SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/) const plain = await new Promise(resolve => { const req = http.request({ host: '127.0.0.1', port: t.port, path: '/api/sessions' }, res => resolve(String(res.statusCode)) ) req.on('error', (error: NodeJS.ErrnoException) => resolve(String(error.code || 'error'))) req.end() }) expect(plain).not.toBe('200') await t.proxy.stop() }) it('after stop() nothing listens (ECONNREFUSED) and open sockets are closed', async () => { const t = await startProxy() const { token } = await t.pair() const live = await wsOpen(`wss://127.0.0.1:${t.port}/api/ws?token=${token}`, t.ca) const closed = new Promise(resolve => live.ws?.once('close', resolve)) expect(await connectOutcome(t.port)).toBe('open') await t.proxy.stop() await closed expect(await connectOutcome(t.port)).toBe('ECONNREFUSED') expect(t.proxy.listening).toBe(false) }) it('logs method + path + status only: no device token, pairing code, session token, query or body', async () => { const t = await startProxy() const { code, token } = await t.pair('Gizli Telefon') await call(t.port, 'GET', `/api/sessions?token=${token}&q=sorgu-metni`, { ca: t.ca }) await call(t.port, 'GET', `/api/sessions/${token}`, { ca: t.ca, token }) await call(t.port, 'POST', '/api/sessions', { ca: t.ca, token, body: { secret: 'govde-metni' } }) await call(t.port, 'GET', '/api/env', { ca: t.ca, token }) await call(t.port, 'GET', '/api/sessions', { ca: t.ca, token: SESSION }) await call(t.port, 'POST', '/aiturk/pair/claim', { ca: t.ca, body: { code, device_name: 'x' } }) await wsOpen(`wss://127.0.0.1:${t.port}/api/pty?token=${token}`, t.ca) const text = t.logs.join('\n') expect(t.logs).toContain('POST /aiturk/pair/claim 200') expect(t.logs).toContain('GET /api/sessions 200') expect(t.logs).toContain('GET /api/env 403') expect(t.logs).toContain('GET /api/sessions 401') expect(t.logs).toContain('GET /api/pty 403') expect(text).not.toContain('amd_') expect(text).not.toContain(token.slice(4)) expect(text).not.toContain(code) expect(text).not.toContain(SESSION) expect(text).not.toContain('sorgu-metni') expect(text).not.toContain('govde-metni') expect(text).not.toContain('Gizli Telefon') await t.proxy.stop() }) }) // ── controller ────────────────────────────────────────────────────────────── // Stand-in for the LAN bind (production: 0.0.0.0). Still loopback, so no test socket leaves this machine, // but it is a DIFFERENT address from 127.0.0.1, which is what "listening on the LAN or not" needs. const LAN_HOST = '127.0.0.2' const RELAY_IDS = [ '3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f' ] const HOST_TOKENS = RELAY_IDS.map((_id, index) => `tsmr_${String.fromCharCode(65 + index).repeat(43)}`) /** Host socket that opens at once and never carries traffic; enough for the controller tests. */ class FakeHostSocket extends EventEmitter { sent: (Buffer | string)[] = [] closed: null | number = null bufferedAmount = 0 constructor( public url: string, public headers: Record ) { super() setTimeout(() => this.emit('open'), 0) } send(data: Buffer | string) { this.sent.push(data) } close(code = 1000) { if (this.closed === null) { this.closed = code setTimeout(() => this.emit('close', code), 0) } } } function linkHarness(over: Record = {}) { const vault = { value: { accountToken: 'eski-alan' } as Record } const store = { value: (over.stored ?? null) as any, saves: 0 } const firewallCalls: string[] = [] const firewallResults: any[] = over.firewallResults ?? [] const fetchCalls: { url: string; init: any }[] = [] const events: MobileLinkEvent[] = [] const logs: string[] = [] const hostSockets: FakeHostSocket[] = [] /** Outcomes of successive `POST /offers` calls (status or 'throw'); afterwards `state.offerStatus` applies. */ const offerPlan: (number | 'throw')[] = [...(over.offerPlan ?? [])] /** Waits the controller asked for between tries (real values); they are shortened to 1 ms here. */ const retrySleeps: number[] = [] /** With `holdRetries` a wait ends only when the test calls the function. */ const heldRetries: (() => void)[] = [] const RETRY_WAITS = new Set([...PAIR_OFFER_RETRY_DELAYS_MS]) const state = { remote: false, offerStatus: over.offerStatus ?? 201, /** While set, `POST /offers` answers only after it resolves. */ offerGate: null as null | Promise, /** false = `POST /sessions` fails (no internet). */ sessions: over.sessions ?? true, sessionCount: 0, /** What `netsh … show rule` would answer. */ rule: (over.rule ?? { state: 'absent' }) as any } const deps = { vault: { load: async () => structuredClone(vault.value), save: async (value: Record) => { vault.value = structuredClone(value) } }, store: { load: async () => structuredClone(store.value), save: async (value: any) => { store.value = structuredClone(value) store.saves++ } }, resolveBackend, isRemoteMode: () => state.remote, firewall: { addRule: async (port: number) => { firewallCalls.push(`add:${port}`) const result = firewallResults.shift() ?? { ok: true } if (result.ok) { state.rule = { state: 'present', ports: [port] } } return result }, removeRule: async () => { firewallCalls.push('remove') state.rule = { state: 'absent' } return { ok: true } }, queryRule: async () => { firewallCalls.push('query') return structuredClone(state.rule) } }, fetch: async (url: string, init: any) => { fetchCalls.push({ url, init }) if (url.endsWith('/mobile-relay/sessions')) { if (!state.sessions) { throw new Error('network down') } const index = state.sessionCount++ return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) } } const isOffer = url.endsWith('/offers') const offerOutcome = isOffer && offerPlan.length ? offerPlan.shift() : state.offerStatus if (isOffer && state.offerGate) { await state.offerGate } if (offerOutcome === 'throw') { throw new Error('network down') } if (isOffer) { return { status: offerOutcome, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 }) } } return { status: 200, json: async () => ({ cancelled: true }) } }, relayConnect: (url: string, headers: Record) => { const socket = new FakeHostSocket(url, headers) hostSockets.push(socket) return socket }, addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'], version: '1.0.0-test', pcName: 'TEST-PC', platform: 'win32', listenHost: LAN_HOST, ports: [0], // Real timers, except the waits between tries of a request: those are recorded and cut to 1 ms. setTimer: (fn: () => void, ms: number) => { if (!RETRY_WAITS.has(ms)) { return setTimeout(fn, ms) } retrySleeps.push(ms) if (over.holdRetries) { heldRetries.push(fn) return null } return setTimeout(fn, 1) }, clearTimer: (handle: unknown) => clearTimeout(handle as NodeJS.Timeout), log: (line: string) => logs.push(line) } const make = () => { const link = createMobileLink(deps as any) link.onEvent(event => events.push(event)) return link } /** Firewall calls that change something (the read-only query is asked often). */ const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query') const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers')) return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries } } describe('mobile link controller', () => { it('nothing listens until the feature is on AND pairing runs or a phone is paired', async () => { const h = linkHarness() expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, port: null, pairing: null, devices: [] }) const enabled = await h.link.enable() const port = enabled.port as number expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'needed', remote: false }) // Turning the feature on never touches the firewall (no UAC prompt): only the read-only query runs. expect(h.firewallWrites()).toEqual([]) expect(await connectOutcome(port)).toBe('ECONNREFUSED') const pairing = await h.link.pairBegin() expect(pairing.listening).toBe(true) expect(await connectOutcome(port)).toBe('open') const cancelled = await h.link.pairCancel() expect(cancelled).toMatchObject({ listening: false, pairing: null }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') await h.link.enable() expect(h.firewallWrites()).toEqual([]) await h.link.stop() }) it('pairing: QR payload, rendezvous body without the raw code, claim over pinned TLS, "paired" event, cancel of the offer', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() const pairing = state.pairing! const port = state.port as number const fp = h.vault.value.lanTls.fingerprint expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`)) // Default (direct LAN access off): no `u`, only the fingerprint, the code and the relay id. expect(pairing.qr).toBe(`aiturk://pair?v=1&fp=${fp}&c=${pairing.code}&r=${RELAY_IDS[0]}`) expect(pairing.securityCode).toBe(fp.slice(0, 8)) expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/) expect(pairing.codePath).toBe('pending') await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) const offer = h.offers()[0] const body = JSON.parse(offer.init.body) expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers') expect(offer.init.method).toBe('POST') expect(offer.init.redirect).toBe('error') expect(offer.init.signal).toBeInstanceOf(AbortSignal) expect(body).toEqual({ lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'), urls: [], fp, relay: RELAY_IDS[0] }) expect(offer.init.body).not.toContain(pairing.code) expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code) const ca = h.vault.value.lanTls.cert const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: pairing.code, device_name: 'Pixel 9' } }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) const paired = h.events.find(event => event.type === 'paired') as Extract expect(paired.name).toBe('Pixel 9') expect(paired.state).toMatchObject({ listening: true, pairing: null }) expect(paired.state.devices.map(device => device.name)).toEqual(['Pixel 9']) // Disk: digest only; the private key only in the vault; old vault fields kept. const disk = JSON.stringify(h.store.value) expect(disk).not.toContain('amd_') expect(disk).not.toContain(claim.json.token.slice(4)) expect(disk).not.toContain('PRIVATE KEY') expect(h.store.value.devices[0].tokenHash).toBe(crypto.createHash('sha256').update(claim.json.token).digest('hex')) expect(h.vault.value.accountToken).toBe('eski-alan') // The gateway record is cancelled with its own token once the code is spent. await until(() => h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))) const cancel = h.fetchCalls.find(entry => entry.url.endsWith('/cancel'))! expect(cancel.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/cancel') expect(cancel.init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`) expect(cancel.init.redirect).toBe('error') // Paired phone works; still listening after pairing ended. expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) // Nothing secret reached the log or any event. const text = h.logs.join('\n') expect(text).not.toContain('amd_') expect(text).not.toContain(pairing.code) expect(JSON.stringify(h.events.filter(event => event.type === 'paired'))).not.toContain(pairing.code) // Revoke through the controller: token dies, and with no phone left the socket closes. const live = await wsOpen(`wss://127.0.0.1:${port}/api/ws?token=${claim.json.token}`, ca) const closed = new Promise(resolve => live.ws?.once('close', resolve)) const after = await h.link.deviceRevoke(claim.json.device_id) await closed expect(after).toMatchObject({ devices: [], listening: false }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') await expect(h.link.deviceRevoke('not-a-uuid')).rejects.toThrow('aiturk-mobile:invalid_request') await h.link.stop() }) it('when the gateway is unreachable the QR path still works and the code path is marked unavailable', async () => { for (const offerStatus of ['throw', 429, 409]) { const h = linkHarness({ offerStatus }) await h.link.enable() const state = await h.link.pairBegin() await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) const claim = await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) expect(claim.status).toBe(200) expect(h.fetchCalls.some(entry => entry.url.endsWith('/cancel'))).toBe(false) await h.link.stop() } }) it('disable stops listening and keeps the phones; the next app start resumes; quitting closes the socket', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() const port = state.port as number const ca = h.vault.value.lanTls.cert const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) await until(() => h.events.some(event => event.type === 'paired')) const off = await h.link.disable() expect(off).toMatchObject({ enabled: false, listening: false }) expect(off.devices).toHaveLength(1) expect(await connectOutcome(port)).toBe('ECONNREFUSED') expect(h.firewallWrites()).toEqual([]) // neither enabling nor disabling touches the rule await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled') // Disabled at app start: no socket. const restartedOff = h.make() expect(await restartedOff.start()).toMatchObject({ enabled: false, listening: false }) expect(await connectOutcome(port)).toBe('ECONNREFUSED') await restartedOff.enable() await restartedOff.stop() // Enabled with a paired phone at app start: listens on the stored port, same certificate, same token. const restarted = h.make() const resumed = await restarted.start() expect(resumed).toMatchObject({ enabled: true, listening: true, port, lan: { direct: false, listening: false } }) expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200) await restarted.stop() expect(await connectOutcome(port)).toBe('ECONNREFUSED') }) it('remote-gateway mode refuses the feature and never listens', async () => { const h = linkHarness() h.state.remote = true await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError) await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode') expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true }) expect(h.firewallWrites()).toEqual([]) h.state.remote = false await h.link.enable() h.state.remote = true await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:remote_mode') expect((await h.link.start()).listening).toBe(false) await h.link.stop() }) it('"İzin ver" is the only thing that adds the firewall rule; a refused UAC prompt leaves direct access off', async () => { const h = linkHarness({ firewallResults: [ { ok: false, reason: 'denied' }, { ok: false, reason: 'failed' } ] }) const state = await h.link.enable() expect(state).toMatchObject({ enabled: true, firewall: 'needed', lan: { direct: false, listening: false, firewall: 'missing' } }) expect(h.firewallWrites()).toEqual([]) expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } }) expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } }) expect(await h.link.firewallAllow()).toMatchObject({ firewall: 'ok', lan: { direct: true, firewall: 'allowed' } }) expect(h.firewallWrites()).toHaveLength(3) // The rule is there (asked from Windows, not remembered): no second prompt. expect((await h.link.lanAllow()).firewall).toBe('ok') expect(h.firewallWrites()).toHaveLength(3) expect(await h.link.firewallRemove()).toMatchObject({ firewall: 'needed', lan: { direct: false, firewall: 'missing' } }) expect(h.firewallWrites().at(-1)).toBe('remove') await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled') await h.link.stop() }) it('default bind is 127.0.0.1 only; direct LAN access moves the socket to the LAN bind and puts `u` into the QR and the offer', async () => { expect(MOBILE_LOOPBACK_HOST).toBe('127.0.0.1') expect(MOBILE_LAN_DIRECT_HOST).toBe('0.0.0.0') const h = linkHarness() await h.link.enable() const first = await h.link.pairBegin() const port = first.port as number // Listening, but only on loopback: the stand-in LAN address refuses the connection. expect(first).toMatchObject({ listening: true, lan: { direct: false, listening: false, port, firewall: 'missing' } }) expect(await connectOutcome(port, '127.0.0.1')).toBe('open') expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED') expect(first.pairing!.qr).not.toContain('u=') await until(() => h.offers().length === 1) expect(JSON.parse(h.offers()[0].init.body)).toMatchObject({ urls: [], relay: RELAY_IDS[0] }) // The user allows direct access: rule added now, pairing on screen is cancelled, socket moves. const allowed = await h.link.lanAllow() expect(h.firewallWrites()).toEqual([`add:${port}`]) expect(allowed).toMatchObject({ pairing: null, listening: false, lan: { direct: true, listening: false, firewall: 'allowed' } }) expect(h.store.value.lanDirect).toBe(true) const second = await h.link.pairBegin() const fp = h.vault.value.lanTls.fingerprint expect(second).toMatchObject({ listening: true, lan: { direct: true, listening: true, port } }) expect(await connectOutcome(port, LAN_HOST)).toBe('open') expect(second.pairing!.qr).toBe( `aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${second.pairing!.code}&r=${RELAY_IDS[0]}` ) await until(() => h.offers().length === 2) expect(JSON.parse(h.offers()[1].init.body)).toMatchObject({ urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`], relay: RELAY_IDS[0] }) // The rule disappears outside the IDE: the next state query notices and goes back to loopback. h.state.rule = { state: 'absent' } const lost = await h.link.state() expect(lost).toMatchObject({ listening: true, lan: { direct: true, listening: false, firewall: 'missing' } }) expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED') expect(await connectOutcome(port, '127.0.0.1')).toBe('open') // A rule for another port is not permission for this one. h.state.rule = { state: 'present', ports: [port === 47321 ? 47322 : 47321] } expect((await h.link.state()).lan.firewall).toBe('missing') h.state.rule = { state: 'unknown' } expect((await h.link.state()).lan.firewall).toBe('unknown') const off = await h.link.lanDisallow() expect(off).toMatchObject({ pairing: null, lan: { direct: false, listening: false } }) expect(h.firewallWrites()).toEqual([`add:${port}`]) // turning it off needs no prompt await h.link.stop() }) it('a record written by beta.15 (enabled, firewallPort set, no lanDirect) stays on loopback and raises no prompt', async () => { const id = '0f8fad5b-d9cb-469f-a165-70867728950e' const h = linkHarness({ rule: { state: 'present', ports: [47321] }, stored: { v: 1, enabled: true, port: null, firewallPort: 47321, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] } }) const resumed = await h.link.start() expect(resumed).toMatchObject({ enabled: true, listening: true, lan: { direct: false, listening: false } }) expect(await connectOutcome(resumed.port as number, LAN_HOST)).toBe('ECONNREFUSED') expect(h.firewallWrites()).toEqual([]) // The relay follows the same condition as the listener: a paired phone is enough. await until(() => h.events.some(event => event.state.relay.status === 'connected')) expect(h.hostSockets).toHaveLength(1) await h.link.stop() expect(h.hostSockets[0].closed).toBe(1000) expect((await h.link.state()).relay.status).toBe('off') }) it('without a relay registration "Telefon ekle" starts nothing unless direct LAN access is on', async () => { const h = linkHarness({ sessions: false, addresses: ['8.8.8.8', '169.254.3.4'] }) await h.link.enable() await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/) expect(await h.link.state()).toMatchObject({ pairing: null, listening: false, relay: { ready: false, status: 'off' } }) expect(h.offers()).toEqual([]) // Direct access on, but no usable LAN address either. await h.link.lanAllow() await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address') // Internet back: the relay alone is enough, even with no LAN address at all. h.state.sessions = true const state = await h.link.pairBegin() expect(state.pairing!.qr).toMatch( new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`) ) await h.link.stop() const broken = createMobileLink({ vault: { load: async () => { throw new Error(`Windows kasası ${SESSION}`) }, save: async () => undefined }, store: { load: async () => null, save: async () => undefined }, resolveBackend, isRemoteMode: () => false, firewall: { addRule: async () => ({ ok: true }), removeRule: async () => ({ ok: true }) }, fetch: async () => ({ status: 500, json: async () => ({}) }), addresses: async () => [], version: 'x', pcName: 'x', platform: 'win32', listenHost: '127.0.0.1', ports: [0] } as any) await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/) }) it('relay: same on/off condition as the listener, token only in the vault and the Authorization header, renewal drops the phones', async () => { const h = linkHarness() expect((await h.link.enable()).relay).toEqual({ status: 'off', code: null, ready: false, reissued: false }) expect(h.hostSockets).toHaveLength(0) const state = await h.link.pairBegin() const port = state.port as number const ca = h.vault.value.lanTls.cert await until(() => h.events.some(event => event.state.relay.status === 'connected')) expect(h.hostSockets[0].url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[0]}`) expect(h.hostSockets[0].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[0]}` }) const sessions = h.fetchCalls.find(entry => entry.url.endsWith('/sessions'))! expect(sessions.url).toBe('https://turkservis.online/v1/aiturk/mobile-relay/sessions') expect(sessions.init).toMatchObject({ method: 'POST', body: '{}', redirect: 'error' }) expect(sessions.init.signal).toBeInstanceOf(AbortSignal) // relayId on disk, hostToken only in the vault. expect(h.store.value.relayId).toBe(RELAY_IDS[0]) expect(h.vault.value.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) expect(h.vault.value.accountToken).toBe('eski-alan') const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) expect((await h.link.state()).relay).toEqual({ status: 'connected', code: null, ready: true, reissued: false }) // One registration for the whole life of the pairing: the phone's stored id must not change. expect(h.fetchCalls.filter(entry => entry.url.endsWith('/sessions'))).toHaveLength(1) // "Röle kimliğini yenile". const renewed = await h.link.relayRenew() expect(renewed).toMatchObject({ devices: [], listening: false, relay: { status: 'off', ready: true, reissued: true } }) expect(h.store.value.relayId).toBe(RELAY_IDS[1]) expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[1]) expect( (await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status ).not.toBe(200) await until(() => h.hostSockets[0].closed !== null) const again = await h.link.pairBegin() expect(again.pairing!.qr).toContain(`&r=${RELAY_IDS[1]}`) await until(() => h.events.at(-1)?.state.relay.status === 'connected') // Every socket after the renewal carries the new token; the old one is never sent again. expect(h.hostSockets.length).toBeGreaterThan(1) expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe( true ) expect(h.hostSockets.at(-1)!.url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[1]}`) // Renewal that cannot reach the gateway changes nothing. h.state.sessions = false await expect(h.link.relayRenew()).rejects.toThrow('aiturk-mobile:relay_unavailable') expect(h.store.value.relayId).toBe(RELAY_IDS[1]) // The host token never reaches a log line, an event, a state answer or the plain store. const exposed = [ h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value) ].join('\n') expect(exposed).not.toContain('tsmr_') expect(h.logs.filter(line => line.startsWith('relay '))).toEqual( expect.arrayContaining(['relay connecting', 'relay connected', 'relay off']) ) expect( h.logs .filter(line => line.startsWith('relay ')) .every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line)) ).toBe(true) await h.link.stop() }) it('a regenerated certificate drops phones paired against the old one', async () => { const h = linkHarness() await h.link.enable() const state = await h.link.pairBegin() await call(state.port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: state.pairing!.code, device_name: 'Pixel' } }) await until(() => h.events.some(event => event.type === 'paired')) await h.link.stop() delete h.vault.value.lanTls const restarted = h.make() const resumed = await restarted.start() expect(resumed.devices).toEqual([]) expect(resumed.listening).toBe(false) await restarted.stop() }) }) describe('mobile link controller: rendezvous record retries', () => { const lookupOf = (h: ReturnType, index: number) => JSON.parse(h.offers()[index].init.body).lookup const cancels = (h: ReturnType) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel')) const noSecretInLogs = (h: ReturnType, code: string) => { const text = h.logs.join('\n') expect(text).not.toContain(code) expect(text).not.toContain(pairLookup(code)) expect(text).not.toContain('tsmp_') } it('first request rejects, the second answers 201: the code path becomes ready (one lost request does not close it)', async () => { const h = linkHarness({ offerPlan: ['throw', 201] }) await h.link.enable() const state = await h.link.pairBegin() expect(state.pairing!.codePath).toBe('pending') await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) expect(h.offers()).toHaveLength(2) expect(h.retrySleeps).toEqual([500]) // Same record both times, and each try has its own timeout signal. expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) expect(h.offers()[1].init.signal).not.toBe(h.offers()[0].init.signal) expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer published']) noSecretInLogs(h, state.pairing!.code) await h.link.stop() }) it('two 503 answers, then 201: ready on the third try', async () => { const h = linkHarness({ offerPlan: [503, 503, 201] }) await h.link.enable() await h.link.pairBegin() await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) expect(h.offers()).toHaveLength(3) expect(h.retrySleeps).toEqual([500, 1500]) expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) await h.link.stop() }) it('a lost answer followed by 409 means the first request landed: ready, and no cancel is sent without a token', async () => { const h = linkHarness({ offerPlan: ['throw', 409] }) await h.link.enable() await h.link.pairBegin() await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready')) expect(h.offers()).toHaveLength(2) expect((await h.link.pairCancel()).pairing).toBeNull() await new Promise(resolve => setTimeout(resolve, 50)) expect(cancels(h)).toEqual([]) await h.link.stop() expect(cancels(h)).toEqual([]) }) it('422 is final: one try, unavailable', async () => { const h = linkHarness({ offerPlan: [422, 201] }) await h.link.enable() const state = await h.link.pairBegin() await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) await new Promise(resolve => setTimeout(resolve, 50)) expect(h.offers()).toHaveLength(1) expect(h.retrySleeps).toEqual([]) expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 1 try']) noSecretInLogs(h, state.pairing!.code) await h.link.stop() }) it('four failing tries: unavailable only after the fourth, never earlier', async () => { const h = linkHarness({ offerPlan: ['throw', 503, 429, 'throw', 201], holdRetries: true }) await h.link.enable() const state = await h.link.pairBegin() for (let tries = 1; tries <= 3; tries++) { await until(() => h.offers().length === tries && h.heldRetries.length === 1) // Still being tried: the window keeps saying "hazırlanıyor…". expect((await h.link.state()).pairing!.codePath).toBe('pending') expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false) h.heldRetries.shift()!() } await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) expect(h.offers()).toHaveLength(4) expect(h.retrySleeps).toEqual([500, 1500, 3000]) expect(h.heldRetries).toEqual([]) expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 4 tries']) noSecretInLogs(h, state.pairing!.code) await h.link.stop() }) it('cancelling the pairing stops the tries; a record that lands after the cancel is cancelled on the gateway', async () => { const h = linkHarness({ offerPlan: ['throw', 201], holdRetries: true }) await h.link.enable() await h.link.pairBegin() await until(() => h.offers().length === 1 && h.heldRetries.length === 1) expect((await h.link.pairCancel()).pairing).toBeNull() // The wait ends after the pairing is gone: no further request. h.heldRetries.shift()!() await new Promise(resolve => setTimeout(resolve, 50)) expect(h.offers()).toHaveLength(1) expect(cancels(h)).toEqual([]) expect(h.logs.filter(line => line.startsWith('pair '))).toEqual([]) // A NEW pairing is not confused with the old one, and a request in flight when pairing ends is cleaned up. let release: () => void = () => undefined h.state.offerGate = new Promise(resolve => { release = resolve }) await h.link.pairBegin() await until(() => h.offers().length === 2) await h.link.pairCancel() release() await until(() => cancels(h).length === 1) expect(cancels(h)[0].init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`) await h.link.stop() }) it('pair.republish publishes the running pairing again: new request, same code, same QR, same expiry', async () => { const h = linkHarness({ offerPlan: [422] }) await h.link.enable() const begun = (await h.link.pairBegin()).pairing! await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable')) expect(h.offers()).toHaveLength(1) const again = (await h.link.pairRepublish()).pairing! expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' }) await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready') expect(h.offers()).toHaveLength(2) expect(lookupOf(h, 1)).toBe(lookupOf(h, 0)) expect(JSON.parse(h.offers()[1].init.body)).toEqual(JSON.parse(h.offers()[0].init.body)) // Nothing to do while ready (or pending), and nothing without a pairing. expect((await h.link.pairRepublish()).pairing).toMatchObject({ code: begun.code, codePath: 'ready' }) expect(h.offers()).toHaveLength(2) // The code on screen still pairs the phone. const claim = await call((await h.link.state()).port as number, 'POST', '/aiturk/pair/claim', { ca: h.vault.value.lanTls.cert, body: { code: begun.code, device_name: 'Pixel' } }) expect(claim.status).toBe(200) await until(() => h.events.some(event => event.type === 'paired')) expect((await h.link.pairRepublish()).pairing).toBeNull() expect(h.offers()).toHaveLength(2) await h.link.stop() }) it('pair.republish after four lost requests: a 409 on its first try is our own record (ready); after a 422 it is not', async () => { const lost = linkHarness({ offerPlan: ['throw', 'throw', 'throw', 'throw', 409] }) await lost.link.enable() await lost.link.pairBegin() await until(() => lost.events.some(event => event.state.pairing?.codePath === 'unavailable')) expect(lost.offers()).toHaveLength(4) await lost.link.pairRepublish() await until(() => lost.events.at(-1)?.state.pairing?.codePath === 'ready') expect(lost.offers()).toHaveLength(5) await lost.link.stop() expect(cancels(lost)).toEqual([]) const refused = linkHarness({ offerPlan: [422, 409] }) await refused.link.enable() await refused.link.pairBegin() await until(() => refused.events.some(event => event.state.pairing?.codePath === 'unavailable')) await refused.link.pairRepublish() await until(() => refused.offers().length === 2 && refused.events.at(-1)?.state.pairing?.codePath === 'unavailable') await refused.link.stop() }) }) describe('publishPairOffer / cancelPairOffer', () => { const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] } const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` } function plan(outcomes: (number | 'throw')[]) { const sleeps: number[] = [] const logs: string[] = [] let calls = 0 const fetcher = async () => { const outcome = outcomes[calls++] ?? 201 if (outcome === 'throw') { throw new Error('fetch failed') } return { status: outcome, json: async () => good } } const options = { sleep: async (ms: number) => { sleeps.push(ms) }, log: (line: string) => logs.push(line) } return { fetcher, options, sleeps, logs, calls: () => calls } } it('waits 0.5 s, 1.5 s and 3 s between at most four tries', async () => { const p = plan(['throw', 500, 429, 'throw', 201]) expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull() expect(p.calls()).toBe(PAIR_OFFER_MAX_TRIES) expect(p.calls()).toBe(4) expect(p.sleeps).toEqual([500, 1500, 3000]) expect(p.sleeps).toEqual([...PAIR_OFFER_RETRY_DELAYS_MS]) expect(p.logs).toEqual(['offer failed after 4 tries']) }) it('retries a thrown error, 5xx and 429; 201 ends it', async () => { for (const first of ['throw', 500, 502, 503, 504, 429] as const) { const p = plan([first, 201]) expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual(good) expect(p.calls()).toBe(2) expect(p.logs).toEqual(['offer published']) } }) it('does not retry 422 or any other final answer; a first-try 409 is somebody else’s record', async () => { for (const status of [422, 400, 401, 403, 404, 409, 200, 302]) { const p = plan([status, 201]) expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull() expect(p.calls()).toBe(1) expect(p.sleeps).toEqual([]) expect(p.logs).toEqual(['offer failed after 1 try']) } }) it('409 after a lost answer or a 5xx is our own record (no token); 409 after 429 is not', async () => { for (const first of ['throw', 502] as const) { const p = plan([first, 409]) expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual({ offerId: null, cancelToken: null }) expect(p.calls()).toBe(2) expect(p.logs).toEqual(['offer published']) } const limited = plan([429, 409]) expect(await publishPairOffer(limited.fetcher, input, limited.options)).toBeNull() expect(limited.calls()).toBe(2) const told = plan([409]) expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null }) }) it('stops as soon as the pairing is over, without a log line', async () => { const p = plan(['throw', 'throw', 201]) let over = false const result = await publishPairOffer(p.fetcher, input, { ...p.options, cancelled: () => over, sleep: async ms => { p.sleeps.push(ms) over = true } }) expect(result).toBeNull() expect(p.calls()).toBe(1) expect(p.logs).toEqual([]) }) it('cancelPairOffer sends nothing when there is no token', async () => { const urls: string[] = [] const fetcher = async (url: string) => { urls.push(url) return { status: 200, json: async () => ({}) } } await cancelPairOffer(fetcher, { offerId: null, cancelToken: null }) expect(urls).toEqual([]) await cancelPairOffer(fetcher, good) expect(urls).toEqual(['https://turkservis.online/v1/aiturk/mobile-pair/cancel']) }) }) describe('pure helpers', () => { it('pairing code: alphabet, normalisation, display form, lookup digest', () => { for (let i = 0; i < 200; i++) { expect(generatePairCode()).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/) } expect(generatePairCode(() => Buffer.from([0, 31, 32, 63, 255, 8, 9, 10]))).toBe('A9A99JKL') expect(normalizePairCode(' abcd-efgh ')).toBe('ABCDEFGH') expect(normalizePairCode(12345678)).toBe('') expect(formatPairCode('ABCDEFGH')).toBe('ABCD-EFGH') expect(pairLookup('abcd-efgh')).toBe( crypto.createHash('sha256').update('aiturk-mobile-pair-v1:ABCDEFGH').digest('hex') ) }) it('address candidates: private IPv4 only, default route first, virtual adapters dropped, at most four', () => { expect(['10.1.2.3', '172.16.0.1', '172.31.255.254', '192.168.0.1'].every(isPrivateIpv4)).toBe(true) expect( ['172.15.0.1', '172.32.0.1', '8.8.8.8', '169.254.1.1', '127.0.0.1', '192.168.1', '::1', '192.168.1.999'].some( isPrivateIpv4 ) ).toBe(false) const v4 = (address: string, internal = false) => [{ address, family: 'IPv4', internal }] const interfaces = { 'vEthernet (WSL (Hyper-V firewall))': v4('172.28.0.1'), 'VirtualBox Host-Only Network': v4('192.168.56.1'), 'VMware Network Adapter VMnet8': v4('192.168.80.1'), 'Loopback Pseudo-Interface 1': v4('127.0.0.1', true), Ethernet: [{ address: 'fe80::1', family: 'IPv6', internal: false }, ...v4('10.0.0.5')], 'Wi-Fi': v4('192.168.1.20'), Mobile: v4('100.70.1.2'), 'Ethernet 2': v4('192.168.2.2'), 'Ethernet 3': v4('192.168.3.2'), 'Ethernet 4': v4('192.168.4.2') } expect(selectLanAddresses(interfaces, '192.168.1.20')).toEqual([ '192.168.1.20', '10.0.0.5', '192.168.2.2', '192.168.3.2' ]) expect(selectLanAddresses(interfaces, null)[0]).toBe('10.0.0.5') expect(selectLanAddresses({ 'vEthernet (Default Switch)': v4('172.20.0.1') }, null)).toEqual([]) expect(() => buildPairQrPayload({ addresses: ['8.8.8.8'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' }) ).toThrow('aiturk-mobile:no_lan_address') expect( buildPairQrPayload({ addresses: ['1.1.1.1', '10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4', '10.0.0.5'], port: 47321, fingerprint: 'f'.repeat(43), code: 'ABCDEFGH' }) ).toBe( `aiturk://pair?v=1&u=10.0.0.1:47321&u=10.0.0.2:47321&u=10.0.0.3:47321&u=10.0.0.4:47321&fp=${'f'.repeat(43)}&c=ABCDEFGH` ) }) it('forbidden list', () => { expect(isPhoneForbidden('GET', '/api/providers/oauth')).toBe(false) expect(isPhoneForbidden('PATCH', '/api/providers/oauth')).toBe(true) expect(isPhoneForbidden('HEAD', '/api/files/stream')).toBe(false) expect(isPhoneForbidden('GET', '/api/files/stream/x')).toBe(true) expect(isPhoneForbidden('GET', '/api/environment')).toBe(true) expect(isPhoneForbidden('GET', '/api/sessions')).toBe(false) expect(isPhoneForbidden('POST', '/api/sessions')).toBe(false) }) it('firewall command: fixed arguments, numeric port only, no shell; exit codes map to ok / denied / failed', async () => { const add = firewallCommand('add', 47321, 'C:\\Windows') expect(add.file).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') expect(add.args.slice(0, 5)).toEqual(['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']) expect(add.args[5]).toContain("Start-Process -FilePath 'C:\\Windows\\System32\\netsh.exe' -Verb RunAs") expect(add.args[5]).toContain( `-ArgumentList 'advfirewall firewall add rule name="AITURK IDE Telefon Baglantisi" dir=in action=allow protocol=TCP localport=47321 profile=private,public'` ) expect(firewallCommand('remove', 47321).args[5]).toContain(`delete rule name="AITURK IDE Telefon Baglantisi"'`) expect(firewallCommand('add', 47321, "C:\\x'; calc; '").file.startsWith('C:\\Windows\\')).toBe(true) for (const bad of [0, 80, 70000, 1.5, Number.NaN, '47321; calc' as any]) { expect(() => firewallCommand('add', bad)).toThrow('aiturk-mobile:invalid_port') } for (const [exit, expected] of [ [0, { ok: true }], [77, { ok: false, reason: 'denied' }], [1, { ok: false, reason: 'failed' }] ] as const) { const spawned: any[] = [] const firewall = createWindowsFirewall({ platform: 'win32', env: { SystemRoot: 'C:\\Windows' }, spawn: (file, args, options) => { spawned.push({ file, args, options }) return { on: (event: string, listener: (...values: any[]) => void) => { if (event === 'exit') { setTimeout(() => listener(exit), 0) } } } } }) expect(await firewall.addRule(47321)).toEqual(expected) expect(spawned[0].options).toMatchObject({ shell: false, windowsHide: true }) } const never = createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => { throw new Error('must not spawn') } }) expect(await never.addRule(47321)).toEqual({ ok: true }) }) it('stored state is validated on load (bad rows dropped, no token field survives)', () => { const hash = 'a'.repeat(64) const id = '0f8fad5b-d9cb-469f-a165-70867728950e' expect( parseMobileLinkStored({ enabled: 'yes', port: 99999, devices: [ { id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null ] }) ).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }], lanDirect: false, relayId: null, relayReissued: false }) expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [], lanDirect: false, relayId: null, relayReissued: false }) // Only an explicit `true` turns direct LAN access on; a relay id must be a uuid. expect( parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 }) ).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false }) expect( parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true }) ).toMatchObject({ lanDirect: true, relayId: RELAY_IDS[0], relayReissued: true }) }) it('QR payload and rendezvous body: `r` / `relay` is a plain lower-case uuid, `u` / `urls` optional only next to it', async () => { const fp = 'f'.repeat(43) const relay = RELAY_IDS[0] expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe( `aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}` ) expect( buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay }) ).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`) expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe( `aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH` ) // Anything that is not exactly a uuid is dropped, never echoed into the QR. for (const bad of [ `https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, relay.toUpperCase(), `${relay}&u=1.2.3.4:1`, '', 7 as any ]) { expect( buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad }) ).not.toContain('r=') expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad }) ).toThrow('aiturk-mobile:no_lan_address') } const bodies: any[] = [] const fetcher = async (_url: string, init: any) => { bodies.push(JSON.parse(init.body)) return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) } } expect( await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay }) ).not.toBeNull() expect( await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp }) ).not.toBeNull() // Neither a LAN url nor a relay: nothing is published. expect( await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' }) ).toBeNull() expect(bodies).toEqual([ { lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay }, { lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp } ]) }) it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => { const query = firewallQueryCommand('C:\\Windows') expect(query).toEqual({ file: 'C:\\Windows\\System32\\netsh.exe', args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] }) expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe') const english = (port: string, enabled = 'Yes') => [ '', 'Rule Name: AITURK IDE Telefon Baglantisi', '----------------------------------------------------------------------', `Enabled: ${enabled}`, 'Direction: In', 'Profiles: Private,Public', 'Grouping: ', 'LocalIP: Any', 'RemoteIP: Any', 'Protocol: TCP', `LocalPort: ${port}`, 'RemotePort: Any', 'Edge traversal: No', 'Action: Allow', 'Ok.', '' ].join('\r\n') // Turkish Windows, as the bytes arrive when the console code page is read as latin1 (labels garbled on purpose). const turkish = (port: string, enabled = 'Evet') => [ '', 'Kural Adý: AITURK IDE Telefon Baglantisi', '----------------------------------------------------------------------', `Etkin: ${enabled}`, 'Yön: Gelen', 'Profiller: Özel,Ortak', 'Gruplandýrma: ', 'YerelIP: Herhangi', 'UzakIP: Herhangi', 'Ýletiþim Kuralý: TCP', `YerelBağlantıNoktası: ${port}`, 'UzakBağlantıNoktası: Herhangi', 'Kenar geçiþi: Hayýr', 'Eylem: Ýzin Ver', 'Tamam.', '' ].join('\r\n') expect(parseFirewallRuleOutput(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] }) expect(parseFirewallRuleOutput(turkish('47321'), 0)).toEqual({ state: 'present', ports: [47321] }) expect(parseFirewallRuleOutput(english('47330'), 0)).toEqual({ state: 'present', ports: [47330] }) expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ state: 'present', ports: [47321, 47325, 47326] }) // Two rules under the same name (the port changed once): both ports count. expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ state: 'present', ports: [47321, 47322] }) // A disabled rule allows nothing. expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] }) expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 0)).toEqual({ state: 'present', ports: [] }) expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ state: 'absent' }) expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' }) // No answer at all (netsh missing, timeout) is not "no rule". expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' }) expect(parseFirewallRuleOutput('', 1)).toEqual({ state: 'unknown' }) expect(parseFirewallRuleOutput('something unexpected', 0)).toEqual({ state: 'unknown' }) const ran: any[] = [] const firewall = createWindowsFirewall({ platform: 'win32', env: { SystemRoot: 'C:\\Windows' }, spawn: () => { throw new Error('the query must not use the elevated path') }, run: async (file, args) => { ran.push({ file, args }) return { code: 0, stdout: english('47321') } } }) expect(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] }) expect(ran).toEqual([query]) const failing = createWindowsFirewall({ platform: 'win32', env: {}, spawn: () => ({ on: () => undefined }), run: async () => Promise.reject(new Error('x')) }) expect(await failing.queryRule!()).toEqual({ state: 'unknown' }) expect( await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!() ).toEqual({ state: 'unknown' }) }) })