Settings gets a Phone link window: when enabled, the IDE main process runs a small TLS proxy on the local network (self-signed certificate, pinned by its fingerprint) in front of the loopback backend and pairs a phone by QR code or an 8-character code. Each phone gets its own revocable token; the backend session token never leaves the computer. The root page, terminal sockets and secret or settings-changing endpoints are not reachable from a phone. The old WebRTC pairing window is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
1149 lines
45 KiB
TypeScript
1149 lines
45 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 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,
|
||
createLanProxy,
|
||
createMobileLink,
|
||
createWindowsFirewall,
|
||
firewallCommand,
|
||
formatPairCode,
|
||
generatePairCode,
|
||
isPhoneForbidden,
|
||
isPrivateIpv4,
|
||
MobileLinkError,
|
||
type MobileLinkEvent,
|
||
normalizePairCode,
|
||
PAIR_CODE_ALPHABET,
|
||
pairLookup,
|
||
parseMobileLinkStored,
|
||
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) =>
|
||
new Promise<string>(resolve => {
|
||
const socket = net.connect(port, '127.0.0.1')
|
||
|
||
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 ──────────────────────────────────────────────────────────────
|
||
function linkHarness(over: Record<string, any> = {}) {
|
||
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
|
||
const store = { value: 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 state = { remote: false, offerStatus: over.offerStatus ?? 201 }
|
||
|
||
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}`)
|
||
|
||
return firewallResults.shift() ?? { ok: true }
|
||
},
|
||
removeRule: async () => {
|
||
firewallCalls.push('remove')
|
||
|
||
return { ok: true }
|
||
}
|
||
},
|
||
fetch: async (url: string, init: any) => {
|
||
fetchCalls.push({ url, init })
|
||
|
||
if (state.offerStatus === 'throw') {
|
||
throw new Error('network down')
|
||
}
|
||
|
||
if (url.endsWith('/offers')) {
|
||
return {
|
||
status: state.offerStatus,
|
||
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
|
||
}
|
||
}
|
||
|
||
return { status: 200, json: async () => ({ cancelled: true }) }
|
||
},
|
||
addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'],
|
||
version: '1.0.0-test',
|
||
pcName: 'TEST-PC',
|
||
platform: 'win32',
|
||
listenHost: '127.0.0.1',
|
||
ports: [0],
|
||
log: (line: string) => logs.push(line)
|
||
}
|
||
|
||
const make = () => {
|
||
const link = createMobileLink(deps as any)
|
||
|
||
link.onEvent(event => events.push(event))
|
||
|
||
return link
|
||
}
|
||
|
||
return { link: make(), make, vault, store, firewallCalls, fetchCalls, events, logs, state }
|
||
}
|
||
|
||
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: 'ok', remote: false })
|
||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
||
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')
|
||
// The firewall is asked once per port, not on every enable.
|
||
await h.link.enable()
|
||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
||
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}$`))
|
||
expect(pairing.qr).toBe(`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${pairing.code}`)
|
||
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.fetchCalls[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: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`],
|
||
fp
|
||
})
|
||
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.firewallCalls).toEqual([`add:${port}`]) // disabling never removes 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, firewall: 'ok' })
|
||
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.firewallCalls).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('a refused UAC prompt leaves a clear state and "Yeniden dene" adds the rule', 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: 'denied' })
|
||
expect((await h.link.firewallRetry()).firewall).toBe('failed')
|
||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
||
expect(h.firewallCalls).toHaveLength(3)
|
||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
||
expect(h.firewallCalls).toHaveLength(3)
|
||
expect((await h.link.firewallRemove()).firewall).toBe('needed')
|
||
expect(h.firewallCalls.at(-1)).toBe('remove')
|
||
await h.link.stop()
|
||
})
|
||
|
||
it('without a usable LAN address pairing is refused; a failing vault is reported by code only', async () => {
|
||
const h = linkHarness({ addresses: ['8.8.8.8', '169.254.3.4'] })
|
||
|
||
await h.link.enable()
|
||
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address')
|
||
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('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('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 }] })
|
||
expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [] })
|
||
})
|
||
})
|