/**
* 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' })
})
})