2169 lines
80 KiB
TypeScript
2169 lines
80 KiB
TypeScript
/**
|
||
* 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('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<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' })
|
||
})
|
||
})
|