Files
aiturk-hermes-ide/apps/desktop/electron/mobile-lan-proxy.test.ts
T
yilsemandClaude Opus 5.5 58dbb4523b AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir
Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive
baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes
dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu).

- publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429
  yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin
  yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter).
- Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar.
- Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi
  yeniden denenmez).
- Gunluge yalniz "pair offer published / failed after N tries" duser; kod,
  lookup ve jeton yazilmaz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 14:27:59 +03:00

1880 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, `<script>window.__HERMES_SESSION_TOKEN__="${SESSION}";</script>`, '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<void>(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<string, string>
}
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<string, unknown> = {}) {
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<string>(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<string, unknown> = {}) {
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 <b>9</b> ' } })
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 <b>9</b>')
// 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=<device 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('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<string>((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<string>(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<string>(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<string, string>
) {
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<string, any> = {}) {
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
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<number>([...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<void>,
/** 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<string, any>) => {
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<string, string>) => {
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<MobileLinkEvent, { type: 'paired' }>
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<typeof linkHarness>, index: number) => JSON.parse(h.offers()[index].init.body).lookup
const cancels = (h: ReturnType<typeof linkHarness>) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel'))
const noSecretInLogs = (h: ReturnType<typeof linkHarness>, 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<void>(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' })
})
})