test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts
- updater-fallback.mjs (82 assertions): GitHub probe fallback to Gitea, timeout budgets, in-flight check never stuck in 'checking'; updater.ts gains a setUpdateTimeouts test seam, electron-updater aliased to a stub - ipc-guard.mjs (43): trusted-frame guard exercised through real registerIpc handlers with forged senderFrames; electron-stub now records registrations via globalThis so bundle and test share one instance - log-sanitizer.mjs (71): CSI/OSC/charset state machine, alt-screen fold, byte-split fuzz equal to whole-chunk output; fixes a wrong comment - sftp-timeout.mjs (39): per-op timeouts (metadata 30s, transfer chunk 60s, open 10s) evict half-dead channels with one retry, slow-but-progressing transfers untouched, late rejections never unhandled - reservedAccelerators.ts: single pure isReservedAccelerator shared by the settings recorder and applyGlobalShortcut (the two tables had drifted — main now also refuses Ctrl+=/-/0/PgUp/PgDn legacy values); 56 assertions - ssh-loopback.mjs loads the real ssh.ts via a bundle (50 assertions): TOFU pinning, fail-closed stores, auth gate, connect budget Offline suite grows 13 -> 17. Renderer test framework evaluated: not introducing vitest/jsdom; pure logic keeps being extracted and tested through the existing bundle harness.
This commit is contained in:
1 parent
5ff311ea0f
commit
a0be827650
21 files changed
+2370
-174
No files matched your search
@@ -40,6 +40,12 @@ tests/.session-e2e.cjs
|
||||
tests/.hl-split-smoke.cjs
|
||||
tests/.hl-rules.cjs
|
||||
tests/.zmodem-e2e.cjs
|
||||
tests/.ssh.cjs
|
||||
tests/.updater.cjs
|
||||
tests/.ipc.cjs
|
||||
tests/.ipc-channels.cjs
|
||||
tests/.log-sanitizer.cjs
|
||||
tests/.reserved-accelerators.cjs
|
||||
release/
|
||||
|
||||
# stray local test artifacts
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"pretest": "npm run typecheck",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
|
||||
"predist": "npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import { BrowserWindow, globalShortcut } from 'electron'
|
||||
|
||||
/**
|
||||
* Chords the app owns outright: Ctrl+L is the panic lock, captured in the main
|
||||
* window's before-input-event. The settings recorder (SettingsTabs.tsx,
|
||||
* RESERVED_EXACT_ACCELERATORS) refuses to save it, but that guard only covers
|
||||
* values entered after it existed — a shortcut persisted by an older build
|
||||
* still arrives here, and a global registration intercepts the key at the OS
|
||||
* level even while the window is focused, silently killing the lock shortcut.
|
||||
* Normalized (modifier aliases + case folded) so every spelling is caught.
|
||||
*/
|
||||
const RESERVED_ACCELERATORS = new Set(['control+l', 'commandorcontrol+l'])
|
||||
|
||||
function normalizeAccelerator(accelerator: string): string {
|
||||
return accelerator
|
||||
.split('+')
|
||||
.map((part) => {
|
||||
const p = part.trim().toLowerCase()
|
||||
if (p === 'ctrl') return 'control'
|
||||
if (p === 'cmdorctrl' || p === 'commandorctrl') return 'commandorcontrol'
|
||||
return p
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
import { isReservedAccelerator } from '@shared/reservedAccelerators'
|
||||
|
||||
/**
|
||||
* Register the global show/hide toggle for the main window.
|
||||
*
|
||||
* - accelerator '' / undefined => disabled (no global key bound).
|
||||
* - A reserved chord (Ctrl+L) is skipped: see RESERVED_ACCELERATORS.
|
||||
* - A reserved chord (Ctrl+L, Ctrl+=/-/0/PgUp/PgDn) is skipped: see
|
||||
* @shared/reservedAccelerators for why and for the shared table the settings
|
||||
* recorder uses too.
|
||||
* - Passing an invalid accelerator string makes Electron's register() throw;
|
||||
* we swallow that here so a bad user-supplied value never crashes the app.
|
||||
* - register() returning false means the accelerator is already taken by
|
||||
@@ -39,9 +19,9 @@ export function applyGlobalShortcut(accelerator: string | undefined): void {
|
||||
globalShortcut.unregisterAll()
|
||||
if (!accelerator) return
|
||||
|
||||
if (RESERVED_ACCELERATORS.has(normalizeAccelerator(accelerator))) {
|
||||
if (isReservedAccelerator(accelerator)) {
|
||||
console.warn(
|
||||
`[global-shortcut] "${accelerator}" is reserved for the Ctrl+L lock shortcut; not registering`
|
||||
`[global-shortcut] "${accelerator}" is reserved for an in-app shortcut; not registering`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ export class LogSanitizer {
|
||||
} else if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
} else if (c === '\t' || c >= ' ') {
|
||||
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||
// printable + tab; C0 controls (bell, backspace, …) are dropped.
|
||||
// Note DEL (0x7F) is not a C0 control and passes this test, so it
|
||||
// is kept as an ordinary character.
|
||||
if (this.alt) this.altDirty = true
|
||||
else this.line += c
|
||||
}
|
||||
|
||||
+87
-18
@@ -33,18 +33,87 @@ export function registerSftpClientProvider(provider: (id: string) => Client | un
|
||||
}
|
||||
|
||||
function p<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
|
||||
return bounded(rawP(fn), timeouts.op)
|
||||
}
|
||||
|
||||
/** For ssh2 calls whose callback only yields an error. */
|
||||
function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
|
||||
return bounded(rawVoid(fn), timeouts.op)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer-chunk variants: a 256KB read/write on a slow link is legitimately
|
||||
* seconds, so these run on the wider `transfer` budget instead of the metadata
|
||||
* one. Same class of failure, different tolerance.
|
||||
*/
|
||||
function pTransfer<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
|
||||
return bounded(rawP(fn), timeouts.transfer)
|
||||
}
|
||||
|
||||
function pVoidTransfer(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
|
||||
return bounded(rawVoid(fn), timeouts.transfer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation budgets.
|
||||
*
|
||||
* Two classes, because one number cannot serve both: metadata round trips are
|
||||
* milliseconds on any working link, while a 256KB transfer chunk on a slow link
|
||||
* is legitimately seconds (and the upload path additionally waits on a peer
|
||||
* that ACKs lazily — see the transfer section). The metadata budget is the
|
||||
* "channel is dead" detector; the transfer budget is a backstop for the same
|
||||
* failure at chunk granularity, set wide enough that it cannot fire on a merely
|
||||
* slow transfer.
|
||||
*/
|
||||
const timeouts = { op: 30_000, transfer: 60_000, open: 10_000 }
|
||||
|
||||
/**
|
||||
* Test seam: shrink the budgets so the offline harness does not have to wait
|
||||
* them out. Mirrors setLocalPathPolicy — injected, never read from renderer
|
||||
* input.
|
||||
*/
|
||||
export function setSftpTimeouts(patch: { op?: number; transfer?: number; open?: number }): void {
|
||||
if (patch.op !== undefined) timeouts.op = patch.op
|
||||
if (patch.transfer !== undefined) timeouts.transfer = patch.transfer
|
||||
if (patch.open !== undefined) timeouts.open = patch.open
|
||||
}
|
||||
|
||||
/** Unbounded primitive behind `p` / `pVoid`. */
|
||||
function rawP<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn((err, res) => (err ? reject(err) : resolve(res)))
|
||||
})
|
||||
}
|
||||
|
||||
/** For ssh2 calls whose callback only yields an error. */
|
||||
function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
|
||||
function rawVoid(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(err => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject `promise` once `ms` elapses. ssh2's SFTP callbacks are raw socket
|
||||
* completions: a channel that is half-dead (peer gone, no FIN ever delivered,
|
||||
* the case mobile / NAT'd links produce) accepts the request and then never
|
||||
* calls back — the operation, and the UI spinner behind it, used to wait
|
||||
* forever. The losing side of the race is left pending on purpose: it is only
|
||||
* dropped, never cancelled, so a late reply cannot resurrect the operation.
|
||||
*/
|
||||
function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
// The race may already have been decided by the time this one rejects; an
|
||||
// unhandled rejection would take the whole process down.
|
||||
promise.catch(() => undefined)
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const expiry = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new SftpTimeoutError(t('main.sftp.opTimeout', { seconds: Math.round(ms / 1000) })))
|
||||
}, ms)
|
||||
})
|
||||
return Promise.race([promise, expiry]).finally(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
|
||||
type StatLike = { isDirectory(): boolean; size: number; mtime: number; mode: number; uid: number; gid: number }
|
||||
|
||||
function lstat(sftp: SFTPWrapper, path: string): Promise<StatLike> {
|
||||
@@ -79,9 +148,6 @@ export function formatMode(mode: number): string {
|
||||
*/
|
||||
const sftpCache = new Map<string, SFTPWrapper>()
|
||||
|
||||
/** How long an SFTP subsystem open may take before the client counts as dead. */
|
||||
const SFTP_OPEN_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Our own "session is gone" error. `isTransportError` classifies on the class,
|
||||
* not on the message text: the message is translated, the classifier must not
|
||||
@@ -117,7 +183,7 @@ async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
|
||||
// back; bound the wait (like execQuiet does) so withSftp can evict and retry.
|
||||
const timer = setTimeout(
|
||||
() => reject(new SftpTimeoutError(t('main.sftp.openTimeout'))),
|
||||
SFTP_OPEN_TIMEOUT_MS
|
||||
timeouts.open
|
||||
)
|
||||
try {
|
||||
client.sftp((err, sftp_) => {
|
||||
@@ -189,13 +255,16 @@ export function closeSftp(sessionId: string): void {
|
||||
|
||||
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
|
||||
|
||||
/** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */
|
||||
function readdir(sftp: SFTPWrapper, dir: string): Promise<string[]> {
|
||||
return p<Array<string | { filename: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
|
||||
raw.map(n => (typeof n === 'string' ? n : n.filename))
|
||||
)
|
||||
}
|
||||
|
||||
export function listRemote(sessionId: string, dir: string): Promise<SftpEntry[]> {
|
||||
return withSftp(sessionId, async sftp => {
|
||||
const raw = await new Promise<Array<string | { filename: string }>>((resolve, reject) => {
|
||||
sftp.readdir(dir, (err, names) => (err ? reject(err) : resolve(names)))
|
||||
})
|
||||
// ssh2 readdir yields plain strings OR {filename} objects depending on version/options
|
||||
const names = raw.map(n => (typeof n === 'string' ? n : n.filename))
|
||||
const names = await readdir(sftp, dir)
|
||||
const entries: SftpEntry[] = []
|
||||
const queue = [...names]
|
||||
const base = dir.replace(/\/+$/, '') || '/'
|
||||
@@ -240,10 +309,7 @@ async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean):
|
||||
await pVoid(cb => sftp.unlink(path, cb))
|
||||
return
|
||||
}
|
||||
const raw = await new Promise<Array<string | { filename: string }>>((resolve, reject) => {
|
||||
sftp.readdir(path, (err, names) => (err ? reject(err) : resolve(names)))
|
||||
})
|
||||
const names = raw.map(n => (typeof n === 'string' ? n : n.filename))
|
||||
const names = await readdir(sftp, path)
|
||||
const base = path.replace(/\/+$/, '') || '/'
|
||||
for (const name of names) {
|
||||
const child = `${base}/${name}`
|
||||
@@ -472,7 +538,7 @@ export function uploadRemote(
|
||||
lastEmit = now
|
||||
emit({ transferId: id, kind: 'upload', state: 'running', file: name, bytes: pos, totalBytes: size })
|
||||
}
|
||||
const pr = pVoid(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb))
|
||||
const pr = pVoidTransfer(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb))
|
||||
inflight.add(
|
||||
pr.catch((err: Error) => {
|
||||
firstError = firstError ?? err
|
||||
@@ -485,7 +551,10 @@ export function uploadRemote(
|
||||
await localHandle.close()
|
||||
}
|
||||
await Promise.race([
|
||||
pVoid(cb => sftp.close(handle, cb)),
|
||||
// The stall guard owns this wait (10s), so the close itself stays
|
||||
// unbounded-by-`bounded` — otherwise a timeout landing after the
|
||||
// race is decided would reject with nothing listening.
|
||||
rawVoid(cb => sftp.close(handle, cb)),
|
||||
new Promise<void>((r) => setTimeout(r, 10_000))
|
||||
])
|
||||
await Promise.allSettled(inflight)
|
||||
@@ -547,7 +616,7 @@ export function downloadRemote(
|
||||
const buf = Buffer.alloc(CHUNK)
|
||||
for (;;) {
|
||||
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
|
||||
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
|
||||
const { bytesRead } = await pTransfer<{ bytesRead: number; buffer: Buffer }>(cb =>
|
||||
sftp.read(handle, buf, 0, CHUNK, pos, cb)
|
||||
)
|
||||
if (bytesRead === 0) break
|
||||
|
||||
+29
-10
@@ -23,11 +23,30 @@ const GITHUB_RELEASES_API =
|
||||
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
|
||||
const GITHUB_PROBE_URL =
|
||||
'https://api.github.com/repos/billowliu2/OpenTerminal/releases/latest'
|
||||
const GITHUB_PROBE_TIMEOUT_MS = 20_000
|
||||
/** Overall budget for ONE check attempt — see withTimeout. */
|
||||
const CHECK_TIMEOUT_MS = 30_000
|
||||
/** Changelog / releases-API fetches: a stalled response must not hang the About tab. */
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Time budgets, kept in one mutable object so the offline harness
|
||||
* (tests/updater-fallback.mjs) can drive the timeout and fallback paths without
|
||||
* waiting out the production values. Nothing in the app calls
|
||||
* `setUpdateTimeouts`; the numbers below are the shipping ones.
|
||||
*
|
||||
* - `probe`: GitHub connectivity probe. Short enough that a proxy-less user
|
||||
* falls back to Gitea instead of hanging on a dead proxy/DNS.
|
||||
* - `check`: overall budget for ONE check attempt — see withTimeout, which
|
||||
* exists because electron-updater's own socket idle timeout only fires on
|
||||
* silence, so a slow trickling response can hold an attempt open forever.
|
||||
* - `fetch`: changelog / releases-API fetches; a stalled response must not
|
||||
* hang the About tab.
|
||||
*/
|
||||
const budgets = {
|
||||
probe: 20_000,
|
||||
check: 30_000,
|
||||
fetch: 15_000
|
||||
}
|
||||
|
||||
export function setUpdateTimeouts(patch: Partial<typeof budgets>): void {
|
||||
Object.assign(budgets, patch)
|
||||
}
|
||||
|
||||
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
|
||||
let activeFeed: 'gitea' | 'github' = 'gitea'
|
||||
@@ -113,7 +132,7 @@ async function checkWithFallback(): Promise<void> {
|
||||
if (await probeGithub()) {
|
||||
useFeed('github')
|
||||
try {
|
||||
await withTimeout(autoUpdater.checkForUpdates(), CHECK_TIMEOUT_MS)
|
||||
await withTimeout(autoUpdater.checkForUpdates(), budgets.check)
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('[updater] github feed failed, falling back to gitea:', err)
|
||||
@@ -126,7 +145,7 @@ async function checkWithFallback(): Promise<void> {
|
||||
try {
|
||||
// Bounded as well: a still-stuck GitHub check is handed back to us here, and
|
||||
// it must not leave the state at "checking" forever.
|
||||
await withTimeout(autoUpdater.checkForUpdates(), CHECK_TIMEOUT_MS)
|
||||
await withTimeout(autoUpdater.checkForUpdates(), budgets.check)
|
||||
} catch (err) {
|
||||
if (githubErr === undefined) throw err
|
||||
const giteaErr = err instanceof Error ? err.message : String(err)
|
||||
@@ -166,7 +185,7 @@ async function directFetch(url: string): Promise<Response> {
|
||||
// to the releases APIs instead of leaving the view spinning.
|
||||
return s.fetch(url, {
|
||||
headers: { 'User-Agent': 'OpenTerminal' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
signal: AbortSignal.timeout(budgets.fetch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,7 +200,7 @@ async function probeGithub(): Promise<boolean> {
|
||||
await s.setProxy({ mode: 'system' })
|
||||
const resp = await s.fetch(GITHUB_PROBE_URL, {
|
||||
headers: { 'User-Agent': 'OpenTerminal' },
|
||||
signal: AbortSignal.timeout(GITHUB_PROBE_TIMEOUT_MS)
|
||||
signal: AbortSignal.timeout(budgets.probe)
|
||||
})
|
||||
return resp.ok
|
||||
} catch {
|
||||
@@ -215,7 +234,7 @@ async function fetchChangelog(): Promise<ReleaseNote[]> {
|
||||
try {
|
||||
const resp = await net.fetch(url, {
|
||||
headers: { 'User-Agent': 'OpenTerminal' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
signal: AbortSignal.timeout(budgets.fetch)
|
||||
})
|
||||
if (!resp.ok) continue
|
||||
const data = (await resp.json()) as Array<{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'
|
||||
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
|
||||
import type { ThemeColors } from '@shared/theme'
|
||||
import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n'
|
||||
import { isReservedAccelerator } from '@shared/reservedAccelerators'
|
||||
import { useSettingsStore, useResolvedTheme } from './store'
|
||||
import {
|
||||
DEFAULT_FONT_STACK,
|
||||
@@ -482,28 +483,11 @@ function acceleratorFromEvent(e: React.KeyboardEvent<HTMLInputElement>): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx)
|
||||
* and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the
|
||||
* other modifiers, so any Control combo on one of these keys would shadow the
|
||||
* in-app action — the recorder refuses it instead of saving a shortcut that
|
||||
* silently loses its original meaning.
|
||||
* The reserved-accelerator table (in-app font/tab chords and the Ctrl+L panic
|
||||
* lock) lives in @shared/reservedAccelerators so this recorder and the main
|
||||
* process's registration guard (`applyGlobalShortcut`) cannot drift apart —
|
||||
* they are the two halves of one rule. See that module for the rationale.
|
||||
*/
|
||||
const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'PageUp', 'PageDown'])
|
||||
|
||||
/**
|
||||
* Whole chords the app owns outright, matched exactly (modifier set included).
|
||||
* Ctrl+L is the panic lock, captured in main's before-input-event: a global
|
||||
* registration intercepts the key at the OS level even while this window is
|
||||
* focused, so binding it here would silently disable the lock shortcut.
|
||||
*/
|
||||
const RESERVED_EXACT_ACCELERATORS = new Set(['Control+L'])
|
||||
|
||||
/** true when `accel` (e.g. "Control+Shift+=") collides with an in-app shortcut */
|
||||
function isReservedAccelerator(accel: string): boolean {
|
||||
if (RESERVED_EXACT_ACCELERATORS.has(accel)) return true
|
||||
const parts = accel.split('+')
|
||||
return parts.includes('Control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
|
||||
}
|
||||
|
||||
/** t() falls back to the key itself when a translation is missing. */
|
||||
function tOr(key: string, fallback: string): string {
|
||||
|
||||
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.invalidUidGid': 'Invalid uid/gid',
|
||||
'main.sftp.commandTimeout': 'Command timed out',
|
||||
'main.sftp.openTimeout': 'Opening the SFTP channel timed out',
|
||||
'main.sftp.opTimeout': 'The SFTP operation stopped responding ({seconds}s); the connection is probably dead — reconnect and try again',
|
||||
'main.sftp.commandExitCode': 'Command exited with code {code}',
|
||||
'main.sftp.cancelled': 'Cancelled',
|
||||
'main.sftp.localNotAllowed': 'Local path not authorised: {path}. Pick it again with the "choose file / choose directory" dialog.',
|
||||
|
||||
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.invalidUidGid': '不正な uid/gid',
|
||||
'main.sftp.commandTimeout': 'コマンドがタイムアウトしました',
|
||||
'main.sftp.openTimeout': 'SFTP チャネルのオープンがタイムアウトしました',
|
||||
'main.sftp.opTimeout': 'SFTP 操作が {seconds} 秒応答しません。接続が切断された可能性があります。再接続して再試行してください',
|
||||
'main.sftp.commandExitCode': 'コマンドの終了コード {code}',
|
||||
'main.sftp.cancelled': 'キャンセルしました',
|
||||
'main.sftp.localNotAllowed': 'ローカルパスが許可されていません: {path}。「ファイルを選択 / ディレクトリを選択」ダイアログで選び直してください。',
|
||||
|
||||
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.invalidUidGid': '非法 uid/gid',
|
||||
'main.sftp.commandTimeout': '命令执行超时',
|
||||
'main.sftp.openTimeout': 'SFTP 通道打开超时',
|
||||
'main.sftp.opTimeout': 'SFTP 操作无响应({seconds} 秒),连接可能已中断,请重新连接会话后重试',
|
||||
'main.sftp.commandExitCode': '命令退出码 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
'main.sftp.localNotAllowed': '本地路径未被授权: {path}。请通过「选择文件 / 选择目录」对话框重新选择。',
|
||||
|
||||
@@ -35,6 +35,7 @@ const main: Record<string, string> = {
|
||||
'main.sftp.invalidUidGid': 'uid/gid 無效',
|
||||
'main.sftp.commandTimeout': '命令執行逾時',
|
||||
'main.sftp.openTimeout': 'SFTP 通道開啟逾時',
|
||||
'main.sftp.opTimeout': 'SFTP 操作無回應({seconds} 秒),連線可能已中斷,請重新連線後再試',
|
||||
'main.sftp.commandExitCode': '指令結束碼 {code}',
|
||||
'main.sftp.cancelled': '已取消',
|
||||
'main.sftp.localNotAllowed': '本機路徑未獲授權: {path}。請改用「選擇檔案 / 選擇目錄」對話框重新選擇。',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Accelerators the app binds itself, shared by the two places that must agree
|
||||
* on them:
|
||||
*
|
||||
* - the settings recorder (`SettingsTabs.tsx`) refuses to SAVE such a chord, so
|
||||
* a global registration never shadows the in-app action;
|
||||
* - `applyGlobalShortcut` (main) refuses to REGISTER one. The recorder only
|
||||
* guards values entered after it existed — a shortcut persisted by an older
|
||||
* build still arrives there, and a globalShortcut registration intercepts the
|
||||
* key at the OS level even while the window is focused.
|
||||
*
|
||||
* Both sides used to keep their own table and only the renderer's was covered by
|
||||
* a test; keeping the decision here (pure, no imports) makes the two guards
|
||||
* provably identical and table-testable under plain Node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modifier aliases, folded to Electron's canonical spelling. `cmdorctrl`,
|
||||
* `commandorctrl` and `commandorcontrol` all collapse to `control`: the only
|
||||
* platform this app is packaged for is Windows, where CommandOrControl resolves
|
||||
* to Control, and a legacy value saved with the portable spelling would
|
||||
* otherwise slip past the reserved check into a real registration.
|
||||
*/
|
||||
function canonicalPart(part: string): string {
|
||||
const p = part.trim().toLowerCase()
|
||||
if (p === 'ctrl') return 'control'
|
||||
if (p === 'cmdorctrl' || p === 'commandorctrl' || p === 'commandorcontrol') return 'control'
|
||||
return p
|
||||
}
|
||||
|
||||
/** Split an accelerator into its canonical parts (`Ctrl+L` -> `['control','l']`). */
|
||||
export function acceleratorParts(accelerator: string): string[] {
|
||||
return accelerator.split('+').map(canonicalPart)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole chords the app owns outright, matched exactly (modifier set included).
|
||||
* Ctrl+L is the panic lock, captured in main's before-input-event: a global
|
||||
* registration intercepts the key at the OS level even while this window is
|
||||
* focused, so binding it would silently disable the lock shortcut.
|
||||
*/
|
||||
const RESERVED_EXACT = new Set(['control+l'])
|
||||
|
||||
/**
|
||||
* Keys this app binds while Control is held: font size (Ctrl+=/-/0, main.tsx)
|
||||
* and tab cycling (Ctrl+PgUp/PgDn, Workspace). Neither handler looks at the
|
||||
* other modifiers, so any Control combo on one of these keys would shadow the
|
||||
* in-app action — the recorder refuses it, and the main process must not
|
||||
* register a legacy value that has the same effect.
|
||||
*/
|
||||
const RESERVED_CONTROL_KEYS = new Set(['=', '-', '0', 'pageup', 'pagedown'])
|
||||
|
||||
/**
|
||||
* True when `accelerator` collides with a shortcut the app already answers
|
||||
* itself (e.g. `Control+Shift+=`, `Ctrl+L`).
|
||||
*/
|
||||
export function isReservedAccelerator(accelerator: string): boolean {
|
||||
const parts = acceleratorParts(accelerator)
|
||||
if (RESERVED_EXACT.has(parts.join('+'))) return true
|
||||
return parts.includes('control') && RESERVED_CONTROL_KEYS.has(parts[parts.length - 1])
|
||||
}
|
||||
+29
-2
@@ -13,9 +13,16 @@ const esbuild = require('esbuild')
|
||||
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
|
||||
/** Loader tweaks every bundle shares: `?asset` imports land on text loaders. */
|
||||
const LOADERS = { '.png': 'text' }
|
||||
|
||||
const BUNDLES = [
|
||||
// Real session layer: pty.ts also re-exports the ssh + sysinfo engines.
|
||||
{ entry: 'src/main/pty.ts', out: 'tests/.session-e2e.cjs', external: ['@lydell/node-pty', 'ssh2'] },
|
||||
// The real SSH service on its own (no electron surface at all), so the
|
||||
// loopback harness can exercise the shipped connect/verify/shell code
|
||||
// instead of a hand-copied ConnectConfig.
|
||||
{ entry: 'src/main/ssh.ts', out: 'tests/.ssh.cjs', external: ['ssh2'] },
|
||||
// ESM (`.mjs`): tests/sftp-*.mjs load it with `await import()`.
|
||||
{ entry: 'src/main/sftp.ts', out: 'tests/.sftp-svc.mjs', format: 'esm', external: ['ssh2'] },
|
||||
{ entry: 'src/main/commands.ts', out: 'tests/.commands-store.cjs' },
|
||||
@@ -36,6 +43,25 @@ const BUNDLES = [
|
||||
{ entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.cjs' },
|
||||
// Lock keyboard classifier: pure, so the bundle needs no electron surface.
|
||||
{ entry: 'src/main/lockShortcuts.ts', out: 'tests/.lock-shortcuts.cjs' },
|
||||
// Reserved-accelerator table shared by the settings recorder and main's
|
||||
// registration guard: pure, table-tested.
|
||||
{ entry: 'src/shared/reservedAccelerators.ts', out: 'tests/.reserved-accelerators.cjs' },
|
||||
// Update service: feed probe/fallback. `electron-updater` is aliased to a
|
||||
// stub (the real package boots Electron), and the shell half (tray.ts) pulls
|
||||
// a `?asset` import, hence LOADERS.
|
||||
{ entry: 'src/main/updater.ts', out: 'tests/.updater.cjs', alias: { 'electron-updater': './tests/electron-updater-stub.cjs' } },
|
||||
// IPC sender-frame guard: driven through the real registerIpc registration
|
||||
// path (the stub records handlers instead of dropping them).
|
||||
{
|
||||
entry: 'src/main/ipc.ts',
|
||||
out: 'tests/.ipc.cjs',
|
||||
external: ['ssh2', '@lydell/node-pty', 'font-list', 'cpu-features']
|
||||
},
|
||||
// Session-log plain-text transformer: ANSI state machine + alt-screen folding.
|
||||
{ entry: 'src/main/logSanitizer.ts', out: 'tests/.log-sanitizer.cjs' },
|
||||
// Channel-name constants, so a test can name channels instead of inlining
|
||||
// string literals that would silently drift from src/shared/ipc.ts.
|
||||
{ entry: 'src/shared/ipc.ts', out: 'tests/.ipc-channels.cjs' },
|
||||
// zmodem.js stays bundled (NOT external) — the test drives a second in-process
|
||||
// Sentry from the same library.
|
||||
{ entry: 'src/main/zmodem.ts', out: 'tests/.zmodem-e2e.cjs', external: ['ssh2'] },
|
||||
@@ -45,7 +71,7 @@ const BUNDLES = [
|
||||
{ entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' }
|
||||
]
|
||||
|
||||
for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) {
|
||||
for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) {
|
||||
esbuild.buildSync({
|
||||
absWorkingDir: ROOT,
|
||||
entryPoints: [path.join(ROOT, entry)],
|
||||
@@ -54,7 +80,8 @@ for (const { entry, out, format = 'cjs', external = [] } of BUNDLES) {
|
||||
platform: 'node',
|
||||
format,
|
||||
external,
|
||||
alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared' },
|
||||
loader: LOADERS,
|
||||
alias: { electron: './tests/electron-stub.cjs', '@shared': './src/shared', ...alias },
|
||||
logLevel: 'warning'
|
||||
})
|
||||
console.log(`built ${out}`)
|
||||
|
||||
+73
-8
@@ -2,24 +2,76 @@
|
||||
// (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled
|
||||
// modules touch.
|
||||
//
|
||||
// `app.getPath('userData')` is configurable via `__setUserData` so a test can
|
||||
// point the settings store at a temp dir and exercise settings-driven behavior.
|
||||
let userDataPath = process.env.OT_STUB_USERDATA || ''
|
||||
// Every mutable piece of state lives on `globalThis.__otElectronStub` rather
|
||||
// than in this module's scope: the bundles INLINE this file (esbuild aliases
|
||||
// `electron` to it), so a test requiring both `./electron-stub.cjs` and a
|
||||
// bundle would otherwise get two independent copies, and registrations made by
|
||||
// the bundled code would be invisible to the test.
|
||||
//
|
||||
// `app.getPath('userData')` defaults to `OT_STUB_USERDATA` and is also settable
|
||||
// via `__setUserData`, so a test can point the settings store at a temp dir and
|
||||
// exercise settings-driven behavior.
|
||||
const state = (globalThis.__otElectronStub ??= {
|
||||
handlers: new Map(),
|
||||
userDataPath: process.env.OT_STUB_USERDATA || '',
|
||||
isPackaged: false,
|
||||
sessions: new Map()
|
||||
})
|
||||
|
||||
/** Fetch double for the updater bundle: `net.fetch` and every session's fetch. */
|
||||
const noFetch = async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '',
|
||||
json: async () => []
|
||||
})
|
||||
const callFetch = (url, init) =>
|
||||
globalThis.__otFetch ? globalThis.__otFetch(url, init) : noFetch()
|
||||
|
||||
const fakeSession = (name) => {
|
||||
let s = state.sessions.get(name)
|
||||
if (!s) {
|
||||
s = {
|
||||
name,
|
||||
proxyCalls: [],
|
||||
setProxy: async (cfg) => {
|
||||
s.proxyCalls.push(cfg)
|
||||
},
|
||||
fetch: callFetch
|
||||
}
|
||||
state.sessions.set(name, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => []
|
||||
},
|
||||
app: {
|
||||
getPath: (name) => {
|
||||
if (name === 'userData' && userDataPath) return userDataPath
|
||||
if (name === 'userData' && state.userDataPath) return state.userDataPath
|
||||
throw new Error(`electron stub: app.getPath(${name}) is not configured`)
|
||||
},
|
||||
getVersion: () => '0.0.0-stub',
|
||||
setLoginItemSettings: () => {},
|
||||
isPackaged: false
|
||||
get isPackaged() {
|
||||
return state.isPackaged
|
||||
}
|
||||
},
|
||||
ipcMain: {
|
||||
handle: () => {},
|
||||
on: () => {}
|
||||
handle: (channel, listener) => {
|
||||
state.handlers.set(channel, listener)
|
||||
},
|
||||
on: (channel, listener) => {
|
||||
state.handlers.set(`on:${channel}`, listener)
|
||||
}
|
||||
},
|
||||
session: {
|
||||
fromPartition: (name) => fakeSession(name)
|
||||
},
|
||||
net: {
|
||||
fetch: callFetch
|
||||
},
|
||||
globalShortcut: {
|
||||
register: () => true,
|
||||
@@ -39,6 +91,9 @@ module.exports = {
|
||||
shell: {
|
||||
openPath: async () => ''
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] })
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false
|
||||
},
|
||||
@@ -63,6 +118,16 @@ module.exports = {
|
||||
createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) })
|
||||
},
|
||||
__setUserData: (p) => {
|
||||
userDataPath = p
|
||||
state.userDataPath = p
|
||||
},
|
||||
__setPackaged: (v) => {
|
||||
state.isPackaged = v
|
||||
},
|
||||
/** channel -> listener (invoke), `on:<channel>` -> listener (send). */
|
||||
get __handlers() {
|
||||
return state.handlers
|
||||
},
|
||||
get __sessions() {
|
||||
return state.sessions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Stand-in for the `electron-updater` package under plain Node (tests only).
|
||||
//
|
||||
// The real package loads Electron's app/browser-window machinery at require
|
||||
// time, so it cannot be exercised in the offline harness. `tests/build-bundles.cjs`
|
||||
// aliases `electron-updater` to this file, which means it is *INLINED* into the
|
||||
// updater bundle — the bundle does not require this path at runtime, so the test
|
||||
// process cannot reach the bundle's instance by requiring it either. Control
|
||||
// therefore flows through a global set up by the test before it requires the
|
||||
// bundle:
|
||||
//
|
||||
// globalThis.__otAutoUpdater = {
|
||||
// checkForUpdates: () => Promise, // called by the service under test
|
||||
// downloadUpdate: () => Promise,
|
||||
// quitAndInstallCalls: [], // quitAndInstall(...) arguments
|
||||
// feeds: [], // setFeedURL argument per call
|
||||
// proxies: [], // netSession.setProxy argument per call
|
||||
// live // the instance the bundle wired (see on())
|
||||
// }
|
||||
//
|
||||
// Every field is optional; missing hooks fall back to a resolving promise so a
|
||||
// test only has to configure what it asserts on.
|
||||
const { EventEmitter } = require('node:events')
|
||||
|
||||
function ctl() {
|
||||
return (globalThis.__otAutoUpdater ??= {})
|
||||
}
|
||||
|
||||
class FakeAutoUpdater extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.logger = null
|
||||
this.autoDownload = true
|
||||
this.autoInstallOnAppQuit = false
|
||||
this.netSession = {
|
||||
setProxy: async (cfg) => {
|
||||
ctl().proxies?.push(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoever subscribes is the instance the app under test actually drives, so
|
||||
* that one is published as `live`. This matters because the bundle INLINES
|
||||
* this file: the test process holds two instances (its own require of this
|
||||
* path, plus the bundle's copy), and emitting on the wrong one is a no-op.
|
||||
*/
|
||||
on(event, listener) {
|
||||
ctl().live = this
|
||||
return super.on(event, listener)
|
||||
}
|
||||
|
||||
setFeedURL(cfg) {
|
||||
ctl().feeds?.push(cfg)
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
const impl = ctl().checkForUpdates
|
||||
return impl ? impl(ctl()) : Promise.resolve(null)
|
||||
}
|
||||
|
||||
downloadUpdate() {
|
||||
const impl = ctl().downloadUpdate
|
||||
return impl ? impl(ctl()) : Promise.resolve([])
|
||||
}
|
||||
|
||||
quitAndInstall(...args) {
|
||||
ctl().quitAndInstallCalls?.push(args)
|
||||
}
|
||||
}
|
||||
|
||||
const autoUpdater = new FakeAutoUpdater()
|
||||
|
||||
module.exports = { autoUpdater }
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* IPC sender-frame guard self-test (ipc-guard.mjs).
|
||||
*
|
||||
* `installSenderGuard` (src/main/ipc.ts) is the single choke point every IPC
|
||||
* channel in this app is registered through — four modules register handlers,
|
||||
* so the check lives where they all pass rather than at each site. It is what
|
||||
* keeps a frame that navigated away (or an injected one) from driving the main
|
||||
* process through the preload bridge, which is the app's whole API
|
||||
* (`createPty` included). What is pinned here:
|
||||
*
|
||||
* - `isTrustedRendererUrl`: in a packaged build only the bundled renderer
|
||||
* file's URL is trusted; in dev only the vite dev server's ORIGIN (any path
|
||||
* on it, no other port / host). Malformed input is never trusted.
|
||||
* - through the REAL registration path (`registerIpc` -> the stub's ipcMain),
|
||||
* a trusted invoke resolves, an untrusted one rejects with the refused
|
||||
* error instead of reaching the handler, and a missing `senderFrame`
|
||||
* (Electron gives `null` for a destroyed frame) is refused too.
|
||||
* - untrusted `send`-style channels are dropped without calling the listener.
|
||||
* - the guard is installed once, not stacked per registration.
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/ipc-guard.mjs (must exit 0)
|
||||
*/
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const userData = mkdtempSync(join(tmpdir(), 'ot-ipc-'))
|
||||
process.env.OT_STUB_USERDATA = userData
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const stub = require('./electron-stub.cjs')
|
||||
const { registerIpc, isTrustedRendererUrl } = require('./.ipc.cjs')
|
||||
const { Ipc } = require('./.ipc-channels.cjs')
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
else passed += 1
|
||||
}
|
||||
|
||||
const DEV_URL = 'http://localhost:5173'
|
||||
const withDevUrl = (value, fn) => {
|
||||
const prev = process.env.ELECTRON_RENDERER_URL
|
||||
if (value === undefined) delete process.env.ELECTRON_RENDERER_URL
|
||||
else process.env.ELECTRON_RENDERER_URL = value
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.ELECTRON_RENDERER_URL
|
||||
else process.env.ELECTRON_RENDERER_URL = prev
|
||||
}
|
||||
}
|
||||
|
||||
// The URL a packaged build loads: the renderer file next to the main bundle.
|
||||
// The test's bundle sits in tests/, the app's in out/main/, so this is the
|
||||
// `../renderer/index.html` sibling either way.
|
||||
const PACKAGED_URL = pathToFileURL(join(__dirname, '../renderer/index.html')).href
|
||||
|
||||
// ---- 1. the trust predicate -------------------------------------------------
|
||||
console.log('trusted renderer URL (packaged build: the renderer file and nothing else)')
|
||||
withDevUrl(undefined, () => {
|
||||
ok(isTrustedRendererUrl(PACKAGED_URL), 'the bundled renderer file is trusted')
|
||||
ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../renderer/other.html')).href), 'a sibling file in the renderer dir is not')
|
||||
ok(!isTrustedRendererUrl(pathToFileURL(join(__dirname, '../package.json')).href), 'another local file is not')
|
||||
ok(!isTrustedRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts'), 'an unrelated file: URL is not')
|
||||
ok(!isTrustedRendererUrl('https://evil.example/index.html'), 'a remote origin is not')
|
||||
ok(!isTrustedRendererUrl('http://localhost:5173/'), 'the dev server is NOT trusted in a packaged build (env ignored)')
|
||||
})
|
||||
|
||||
console.log('trusted renderer URL (dev build: the dev server origin, any path)')
|
||||
withDevUrl(DEV_URL, () => {
|
||||
ok(isTrustedRendererUrl(`${DEV_URL}/index.html`), 'a path on the dev origin is trusted')
|
||||
ok(isTrustedRendererUrl(`${DEV_URL}/`), 'the dev origin root is trusted')
|
||||
ok(isTrustedRendererUrl(`${DEV_URL}/deep/nested/route?x=1#y`), 'any path/query/hash on that origin is trusted')
|
||||
ok(!isTrustedRendererUrl('http://localhost:5174/index.html'), 'a different port is a different origin')
|
||||
ok(!isTrustedRendererUrl('http://127.0.0.1:5173/index.html'), 'a different host spelling is a different origin')
|
||||
ok(!isTrustedRendererUrl('https://localhost:5173/index.html'), 'a different scheme is a different origin')
|
||||
ok(!isTrustedRendererUrl('https://evil.example/http://localhost:5173/'), 'a hostile URL embedding the dev URL is not the dev origin')
|
||||
ok(!isTrustedRendererUrl(PACKAGED_URL), 'the packaged file URL is not the dev origin')
|
||||
})
|
||||
|
||||
console.log('the predicate refuses everything malformed')
|
||||
withDevUrl(DEV_URL, () => {
|
||||
for (const raw of ['', 'not a url', '://', 'about:blank', 'javascript:alert(1)', 'data:text/html,x', 'file://']) {
|
||||
ok(!isTrustedRendererUrl(raw), `refused: ${JSON.stringify(raw)}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 2. the guard, through the real registration path -----------------------
|
||||
console.log('the guard on a registered channel')
|
||||
registerIpc()
|
||||
|
||||
const handlers = stub.__handlers
|
||||
const trustedFrame = { url: `${DEV_URL}/index.html` }
|
||||
const hostileFrame = { url: 'https://evil.example/hijack.html' }
|
||||
const invoke = (channel, frame, ...args) => {
|
||||
const listener = handlers.get(channel)
|
||||
if (!listener) throw new Error(`no handler registered for ${channel}`)
|
||||
return listener({ senderFrame: frame, sender: { id: 1 } }, ...args)
|
||||
}
|
||||
|
||||
withDevUrl(DEV_URL, () => {
|
||||
ok(typeof handlers.get(Ipc.APP_INFO) === 'function', 'APP_INFO reached the registration path')
|
||||
ok(handlers.get(Ipc.APP_INFO) !== undefined, 'the stub recorded a handler (registrations are not dropped)')
|
||||
|
||||
// The trusted path really executes the handler: it returns the app info
|
||||
// object instead of rejecting.
|
||||
const info = invoke(Ipc.APP_INFO, trustedFrame)
|
||||
ok(
|
||||
info && typeof info === 'object' && typeof info.platform === 'string' && info.appVersion === '0.0.0-stub',
|
||||
`a trusted frame reaches the handler (got ${JSON.stringify(info)})`
|
||||
)
|
||||
|
||||
// Path validation inside a handler still applies to a trusted frame: this
|
||||
// handler refuses non-strings, so a compromised-but-trusted renderer cannot
|
||||
// open an arbitrary path.
|
||||
ok(invoke(Ipc.CWD_OPEN, trustedFrame, 'not-a-path') === false, 'handler-level validation still runs for a trusted frame')
|
||||
ok(invoke(Ipc.CWD_OPEN, trustedFrame, undefined) === false, 'CWD_OPEN refuses a missing path')
|
||||
ok(invoke(Ipc.CWD_OPEN, trustedFrame, 42) === false, 'CWD_OPEN refuses a non-string path')
|
||||
|
||||
const refused = (channel, ...args) => {
|
||||
try {
|
||||
invoke(channel, hostileFrame, ...args)
|
||||
return null
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
let msg = refused(Ipc.APP_INFO)
|
||||
ok(typeof msg === 'string' && msg.includes('untrusted frame'), `an untrusted invoke is refused (${msg})`)
|
||||
ok(typeof msg === 'string' && msg.includes(Ipc.APP_INFO), 'the refusal names the channel (so it is diagnosable)')
|
||||
|
||||
// A hostile frame gets the same refusal on every other invoke channel, and the
|
||||
// handler is never reached: CWD_OPEN would have thrown/misbehaved, PTY_CREATE
|
||||
// would have spawned a shell.
|
||||
for (const channel of [Ipc.CWD_OPEN, Ipc.PTY_CREATE, Ipc.CONNECTIONS_LIST, Ipc.SETTINGS_GET, Ipc.LOCK_STATE_GET]) {
|
||||
if (!handlers.has(channel)) continue
|
||||
const m = refused(channel)
|
||||
ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`)
|
||||
}
|
||||
|
||||
const missingFrame = (() => {
|
||||
try {
|
||||
handlers.get(Ipc.APP_INFO)({ sender: { id: 1 } }, )
|
||||
return null
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
})()
|
||||
ok(
|
||||
typeof missingFrame === 'string' && missingFrame.includes('untrusted frame'),
|
||||
'a missing senderFrame (destroyed frame -> null) is refused'
|
||||
)
|
||||
|
||||
// ---- 3. send-style channels are dropped, not rejected ---------------------
|
||||
// LOG_OPEN_DIR (ipcMain.on) has an observable side effect: it creates the
|
||||
// logs directory. That makes "the listener really did/did not run"
|
||||
// checkable, instead of asserting on the absence of a throw.
|
||||
const send = (frame, ...args) => {
|
||||
const listener = handlers.get(`on:${Ipc.LOG_OPEN_DIR}`)
|
||||
if (!listener) throw new Error('LOG_OPEN_DIR was not registered through ipcMain.on')
|
||||
listener({ senderFrame: frame, sender: { id: 1 } }, ...args)
|
||||
}
|
||||
const logsDir = join(userData, 'logs')
|
||||
|
||||
ok(typeof handlers.get(`on:${Ipc.LOG_OPEN_DIR}`) === 'function', 'LOG_OPEN_DIR is registered through ipcMain.on (and therefore guarded)')
|
||||
|
||||
send(hostileFrame)
|
||||
ok(!existsSync(logsDir), 'an untrusted send is dropped silently — the listener never ran')
|
||||
|
||||
let threw = false
|
||||
try {
|
||||
send({ url: 'not a url' })
|
||||
send(undefined)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw && !existsSync(logsDir), 'send on a malformed / missing frame is dropped, not thrown')
|
||||
|
||||
send(trustedFrame)
|
||||
ok(existsSync(logsDir), 'a trusted send reaches the listener (the logs dir was created)')
|
||||
})
|
||||
|
||||
// ---- 4. installed once ------------------------------------------------------
|
||||
// `installSenderGuard` swaps `ipcMain.handle` / `ipcMain.on` for guarded
|
||||
// wrappers. If it did not short-circuit on the second call, each registration
|
||||
// would be wrapped again and the guard would nest — cheap here, but it also
|
||||
// means a handler added after the first registerIpc could be guarded twice
|
||||
// while one added before is guarded once, and the two spellings of the same
|
||||
// check would drift. The wrapper identity is the observable proof.
|
||||
console.log('the guard is installed once, not stacked')
|
||||
withDevUrl(DEV_URL, () => {
|
||||
const handleRef = stub.ipcMain.handle
|
||||
const onRef = stub.ipcMain.on
|
||||
registerIpc()
|
||||
ok(stub.ipcMain.handle === handleRef, 'a second registerIpc does not re-wrap ipcMain.handle')
|
||||
ok(stub.ipcMain.on === onRef, 'a second registerIpc does not re-wrap ipcMain.on')
|
||||
|
||||
const m = (() => {
|
||||
try {
|
||||
handlers.get(Ipc.APP_INFO)({ senderFrame: hostileFrame, sender: { id: 1 } })
|
||||
return null
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
})()
|
||||
ok(typeof m === 'string' && m.includes('untrusted frame'), 'and an untrusted invoke is still refused after re-registering')
|
||||
|
||||
// The duplicated refusals must not multiply: one layer, one message.
|
||||
ok((m.match(/untrusted frame/g) ?? []).length === 1, 'the refusal message is not nested (exactly one guard layer)')
|
||||
})
|
||||
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[ipc-guard] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[ipc-guard] ALL CHECKS PASSED (${passed} assertions)`)
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Session-log sanitizer self-test (log-sanitizer.mjs).
|
||||
*
|
||||
* src/main/logSanitizer.ts turns raw PTY output into a readable text log
|
||||
* (commands.ts drives it in logWrite/logStop, one instance per logged
|
||||
* session). It is a hand-written state machine over bytes, which is exactly
|
||||
* the shape that fails at chunk boundaries — the PTY hands out arbitrary
|
||||
* splits, and an escape sequence is regularly cut in half between two chunks.
|
||||
* What is pinned here:
|
||||
*
|
||||
* - every escape family the terminal emits is consumed, not printed: CSI
|
||||
* (SGR), OSC terminated by BEL *and* by ST, single-character escapes,
|
||||
* the two-byte charset designators, and an OSC interrupted by a new ESC
|
||||
* - state survives a chunk boundary at every position of the nasty sample
|
||||
* (byte-identical to the single-chunk result), including 1 byte per push
|
||||
* - `\r` collapses an overwritten line (progress bars) while `\r\n` stays a
|
||||
* line ending; a trailing `\r` at flush is an ending too
|
||||
* - alt-screen output is dropped and reported by exactly one marker per
|
||||
* suppressed span, including when the log stops mid-TUI
|
||||
* - a runaway CSI resyncs as text past the 1024-byte cap instead of
|
||||
* swallowing the rest of the session
|
||||
* - DEL / other C0 controls are dropped, tab is preserved
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/log-sanitizer.mjs (must exit 0)
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { LogSanitizer } = require('./.log-sanitizer.cjs')
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
else passed += 1
|
||||
}
|
||||
|
||||
/** Fresh sanitizer + "push these chunks, then flush" through one call. */
|
||||
const run = (...chunks) => {
|
||||
const s = new LogSanitizer()
|
||||
let out = ''
|
||||
for (const c of chunks) out += s.push(c)
|
||||
return { out, out2: out + s.flush() }
|
||||
}
|
||||
|
||||
/** Push, then flush, and require the result. */
|
||||
const feed = (...chunks) => run(...chunks).out2
|
||||
/** Push only — used when the assertion is about what has NOT been emitted yet. */
|
||||
const pushed = (...chunks) => run(...chunks).out
|
||||
|
||||
const MARKER_RE = /\n──── \[[^\]]+\] ────\n/g
|
||||
const markerCount = (s) => (s.match(MARKER_RE) ?? []).length
|
||||
const marker = (() => {
|
||||
const { out } = run('\x1b[?1049h', 'x', '\x1b[?1049l')
|
||||
return out
|
||||
})()
|
||||
|
||||
// ---- 1. plain text and line endings ----------------------------------------
|
||||
console.log('plain text')
|
||||
ok(feed('hello\nworld') === 'hello\nworld', 'an unterminated tail is flushed at the end')
|
||||
ok(feed('hello\n') === 'hello\n', 'a terminated line comes out as-is')
|
||||
ok(pushed('hello\nworld') === 'hello\n', 'nothing is emitted for the pending line until flush')
|
||||
ok(feed('\n') === '\n', 'an empty line is preserved')
|
||||
ok(feed('a\nb\nc') === 'a\nb\nc', 'multiple lines')
|
||||
ok(feed('') === '', 'no input, no output')
|
||||
ok(markerCount(marker) === 1, `the alt-screen marker has the expected shape (${JSON.stringify(marker)})`)
|
||||
|
||||
console.log('\\r vs \\r\\n')
|
||||
ok(feed('progress 10%\rprogress 100%\n') === 'progress 100%\n', '\\r collapses an overwritten progress line')
|
||||
ok(feed('a\rb\rc\n') === 'c\n', 'chained \\r overwrites keep only the last write')
|
||||
ok(feed('line\r\n') === 'line\n', '\\r\\n is a line ending, not an overwrite')
|
||||
ok(feed('one\r\ntwo\r\n') === 'one\ntwo\n', 'several \\r\\n lines')
|
||||
ok(feed('partial\r') === 'partial\n', 'a trailing \\r at flush is treated as a line ending')
|
||||
ok(feed('a\r\rb\n') === 'b\n', 'a doubled \\r still collapses')
|
||||
ok(
|
||||
feed('prog 1\r', 'prog 2\n') === 'prog 2\n',
|
||||
'a \\r at the end of one chunk still overwrites with the next chunk'
|
||||
)
|
||||
ok(feed('prog 1\r', '\n') === 'prog 1\n', 'a \\r at the end of a chunk followed by \\n is an ending')
|
||||
|
||||
// ---- 2. escape sequences are consumed --------------------------------------
|
||||
console.log('CSI / SGR')
|
||||
ok(feed('\x1b[31mred\x1b[0m\n') === 'red\n', 'SGR color codes vanish')
|
||||
ok(feed('\x1b[38;2;1;2;3mtruecolor\x1b[0m\n') === 'truecolor\n', 'truecolor SGR vanishes')
|
||||
ok(feed('\x1b[1;2Hpositioned\n') === 'positioned\n', 'cursor positioning vanishes')
|
||||
ok(feed('\x1b[2J\x1b[Hcleared\n') === 'cleared\n', 'screen clear / home vanish')
|
||||
ok(feed('\x1b[?25lcursor\n') === 'cursor\n', 'a DEC private mode with a trailing l vanishes')
|
||||
|
||||
console.log('OSC (title / hyperlink), both terminators')
|
||||
ok(feed('\x1b]0;title\x07after\n') === 'after\n', 'OSC terminated by BEL is consumed')
|
||||
ok(feed('\x1b]0;title\x1b\\after\n') === 'after\n', 'OSC terminated by ST (ESC \\) is consumed')
|
||||
ok(feed('\x1b]8;;https://example.com\x07link\x1b]8;;\x07\n') === 'link\n', 'OSC 8 hyperlink open+close')
|
||||
ok(
|
||||
feed('\x1b]0;t\x1b[31mx\n') === 'x\n',
|
||||
'an OSC interrupted by a new ESC [ resumes as CSI instead of eating the sequence'
|
||||
)
|
||||
ok(feed('\x1b]0;t\x1b]0;u\x07ok\n') === 'ok\n', 'an OSC interrupted by a new ESC ] continues as OSC')
|
||||
ok(feed('\x1b]0;t\x1bZrest\n') === 'rest\n', 'ESC + an unrelated byte ends the OSC (byte consumed)')
|
||||
|
||||
console.log('single-character escapes and charset designators')
|
||||
ok(feed('\x1b7saved\x1b8restored\n') === 'savedrestored\n', 'DECSC/DECRC (ESC 7 / ESC 8) vanish')
|
||||
ok(feed('\x1b=app\x1b>norm\n') === 'appnorm\n', 'ESC = / ESC > vanish')
|
||||
ok(feed('\x1b(Bplain\n') === 'plain\n', 'the charset designator ESC ( B is consumed whole')
|
||||
ok(feed('\x1b)0line\n') === 'line\n', 'ESC ) 0 is consumed whole')
|
||||
ok(feed('\x1b#8screen\n') === 'screen\n', 'the DECALN designator ESC # 8 is consumed whole')
|
||||
ok(feed('\x1b%Gutf8\n') === 'utf8\n', 'the encoding designator ESC % G is consumed whole')
|
||||
|
||||
console.log('control characters')
|
||||
ok(feed('a\tb\n') === 'a\tb\n', 'tab is preserved')
|
||||
ok(feed('a\x07b\x00c\x1fd\n') === 'abcd\n', 'BEL / NUL / other C0 controls are dropped')
|
||||
// DEL (0x7F) is not a C0 control: it passes the `c >= ' '` test and is kept.
|
||||
// The comment in logSanitizer.ts used to claim otherwise; this pins the real
|
||||
// behaviour so the two cannot drift again.
|
||||
ok(feed('a\x7fb\n') === 'a\x7fb\n', 'DEL is kept as an ordinary character (it is not a C0 control)')
|
||||
|
||||
// ---- 3. chunk boundaries ----------------------------------------------------
|
||||
console.log('state survives chunk boundaries')
|
||||
ok(feed('\x1b', '[31mred\n') === 'red\n', 'ESC at the end of a chunk')
|
||||
ok(feed('\x1b[3', '1mred\n') === 'red\n', 'CSI split mid-parameter')
|
||||
ok(feed('\x1b]', '0;title\x07ok\n') === 'ok\n', 'OSC introducer split')
|
||||
ok(feed('\x1b]0;title\x1b', '\\after\n') === 'after\n', 'ESC of the ST terminator split from its backslash')
|
||||
ok(feed('\x1b(', 'Bplain\n') === 'plain\n', 'charset designator split')
|
||||
ok(feed('\x1b[?1049h', 'a', '\x1b[?1049l', 'b\n') === marker + 'b\n', 'alt-screen toggles split across chunks')
|
||||
|
||||
// A nasty sample exercising every family, cut at every single position: the
|
||||
// concatenated result must be byte-identical to the single-chunk result.
|
||||
const nasty =
|
||||
'\x1b]0;kimi — session\x07' +
|
||||
'\x1b[38;2;79;168;255m╭──╮\x1b[0m\r\n' +
|
||||
'Welcome \x1b[1mbold\x1b[0m\r\n' +
|
||||
'\x1b7' +
|
||||
'\x1b[?25lhidden\x1b[?25h' +
|
||||
'\x1b(Bascii\x1b)0gfx' +
|
||||
'\x1b#8' +
|
||||
'\x1b%Gutf8' +
|
||||
'\x1b[mreset\r' +
|
||||
'overwritten\x1b[K\r\n' +
|
||||
'\x1b[?1049hTUI frame A\r\nTUI frame B\x1b[?1049l' +
|
||||
'\x1b]8;;https://x.example\x1b\\link\x1b]8;;\x1b\\' +
|
||||
'done \u2713\r\n'
|
||||
const reference = feed(nasty)
|
||||
|
||||
{
|
||||
let mismatches = 0
|
||||
for (let i = 1; i < nasty.length - 1; i++) {
|
||||
const got = feed(nasty.slice(0, i), nasty.slice(i))
|
||||
if (got !== reference) {
|
||||
mismatches++
|
||||
if (mismatches <= 3) {
|
||||
console.log(` split ${i}: got ${JSON.stringify(got.slice(0, 90))}`)
|
||||
console.log(` want ${JSON.stringify(reference.slice(0, 90))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(mismatches === 0, `every single split point is byte-identical to the whole-chunk result (${mismatches} mismatches)`)
|
||||
}
|
||||
|
||||
{
|
||||
let got = ''
|
||||
const s = new LogSanitizer()
|
||||
for (const ch of nasty) got += s.push(ch)
|
||||
got += s.flush()
|
||||
ok(got === reference, 'a 1-byte-per-push feed is byte-identical')
|
||||
}
|
||||
|
||||
{
|
||||
const third = Math.floor(nasty.length / 3)
|
||||
ok(
|
||||
feed(nasty.slice(0, third), nasty.slice(third, third * 2), nasty.slice(third * 2)) === reference,
|
||||
'a three-way split is byte-identical'
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
// A random 4-way split (fixed seed via a simple LCG so a failure reproduces).
|
||||
let seed = 12345
|
||||
const rand = (n) => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
||||
return seed % n
|
||||
}
|
||||
let allEqual = true
|
||||
for (let trial = 0; trial < 50; trial++) {
|
||||
const cuts = [1 + rand(nasty.length - 1), 1 + rand(nasty.length - 1), 1 + rand(nasty.length - 1)].sort((a, b) => a - b)
|
||||
const chunks = [nasty.slice(0, cuts[0]), nasty.slice(cuts[0], cuts[1]), nasty.slice(cuts[1], cuts[2]), nasty.slice(cuts[2])]
|
||||
if (feed(...chunks) !== reference) allEqual = false
|
||||
}
|
||||
ok(allEqual, '50 pseudo-random 4-way splits are all byte-identical')
|
||||
}
|
||||
|
||||
// ---- 4. alt screen ---------------------------------------------------------
|
||||
console.log('alt screen (TUI frames) is dropped, one marker per span')
|
||||
ok(feed('\x1b[?1049hframe\r\nframe\x1b[?1049l') === marker, 'a TUI span yields exactly the marker')
|
||||
ok(markerCount(feed('\x1b[?1049hframe\x1b[?1049l')) === 1, 'one span, one marker')
|
||||
ok(
|
||||
markerCount(feed('\x1b[?1049ha\x1b[?1049lb\x1b[?1049hc\x1b[?1049l')) === 2,
|
||||
'two spans, two markers'
|
||||
)
|
||||
ok(feed('\x1b[?1049h\x1b[?1049l') === '', 'an alt-screen toggle with no output emits nothing')
|
||||
ok(pushed('\x1b[?1049hframe') === '', 'TUI output is suppressed while the alt screen is active')
|
||||
ok(feed('before\n\x1b[?1049hframe\x1b[?1049lafter\n') === 'before\n' + marker + 'after\n', 'text around a TUI span survives')
|
||||
|
||||
console.log('alt-screen toggles recognised: 1049 / 1047 / 47')
|
||||
for (const n of ['1049', '1047', '47']) {
|
||||
ok(
|
||||
feed(`\x1b[?${n}htui\x1b[?${n}l`) === marker,
|
||||
`${n}h/${n}l is an alt-screen toggle`
|
||||
)
|
||||
}
|
||||
ok(feed('\x1b[?1048hnotalt\n') === 'notalt\n', '1048 (save cursor) is NOT an alt-screen toggle')
|
||||
ok(feed('\x1b[?25hnotalt\n') === 'notalt\n', 'a private mode with no toggle is not an alt-screen toggle')
|
||||
ok(
|
||||
feed('\x1b[?1049hbody\x1b[?1049l\n') === marker + '\n',
|
||||
'once the alt screen closes, later output is normal again'
|
||||
)
|
||||
|
||||
console.log('stopping the log mid-TUI still reports the suppressed span')
|
||||
ok(feed('normal\n\x1b[?1049hframe') === 'normal\n' + marker, 'flush inside the alt screen emits the marker')
|
||||
ok(feed('normal\n\x1b[?1049h') === 'normal\n', 'flush inside an empty alt screen emits nothing')
|
||||
{
|
||||
// Two spans, the second still open at flush: one marker when it closed, one
|
||||
// for the span the flush interrupted.
|
||||
const out = feed('\x1b[?1049ha\x1b[?1049l\x1b[?1049hb')
|
||||
ok(markerCount(out) === 2, `an open span at flush gets its own marker (${markerCount(out)})`)
|
||||
}
|
||||
|
||||
console.log('a TUI span does not consume the pending normal-buffer line')
|
||||
{
|
||||
// Documented boundary: text committed to the normal buffer before the TUI
|
||||
// opened is still the pending line and is emitted after the span closes.
|
||||
const out = feed('abc\x1b[?1049hdef\x1b[?1049l\n')
|
||||
ok(out === marker + 'abc\n', `pending normal-buffer line survives a TUI span (${JSON.stringify(out)})`)
|
||||
}
|
||||
|
||||
// ---- 5. runaway sequence cap ------------------------------------------------
|
||||
console.log('a runaway CSI resyncs instead of swallowing the session')
|
||||
{
|
||||
// The cap counts the bytes held in `seq` (1024). The byte that trips the cap
|
||||
// is consumed by the resync itself, so the first 1025 parameter bytes are
|
||||
// swallowed and everything after them spills as plain text.
|
||||
const params = '1'.repeat(1100)
|
||||
const out = feed('\x1b[' + params, 'text\n')
|
||||
ok(out.endsWith('text\n'), 'output after the runaway sequence is still logged')
|
||||
ok(out === params.slice(1025) + 'text\n', `exactly the bytes past the 1024 cap become text (${out.length} bytes)`)
|
||||
ok(!out.includes('\x1b'), 'the introducer itself is not leaked into the log')
|
||||
}
|
||||
{
|
||||
// A runaway OSC has no cap (it is terminated by BEL/ST only), so it must stay
|
||||
// swallowed rather than leak the sequence body into the log.
|
||||
const out = feed('\x1b]0;' + 'x'.repeat(5000) + '\x07after\n')
|
||||
ok(out === 'after\n', 'a long but terminated OSC is fully swallowed')
|
||||
}
|
||||
|
||||
// ---- 6. flush is idempotent -------------------------------------------------
|
||||
console.log('flush')
|
||||
{
|
||||
const s = new LogSanitizer()
|
||||
s.push('pending')
|
||||
ok(s.flush() === 'pending', 'the first flush emits the pending line')
|
||||
ok(s.flush() === '', 'a second flush emits nothing')
|
||||
ok(s.push('more').length === 0, 'the sanitizer keeps working after a flush')
|
||||
ok(s.flush() === 'more', 'and flushes the new pending line')
|
||||
}
|
||||
{
|
||||
const s = new LogSanitizer()
|
||||
s.push('\x1b[?1049hframe')
|
||||
const first = s.flush()
|
||||
ok(markerCount(first) === 1, 'flush reports the open TUI span once')
|
||||
ok(s.flush() === '', 'the marker is not repeated by a second flush')
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[log-sanitizer] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[log-sanitizer] ALL CHECKS PASSED (${passed} assertions)`)
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Reserved-accelerator self-test (reserved-accelerators.mjs).
|
||||
*
|
||||
* src/shared/reservedAccelerators.ts is the single table two guards read:
|
||||
* - the settings recorder refuses to SAVE such a chord (SettingsTabs.tsx);
|
||||
* - `applyGlobalShortcut` refuses to REGISTER one (main/globalShortcuts.ts).
|
||||
* They used to be two independent tables with two spellings of the same rule,
|
||||
* and only the renderer's had a test. What is pinned here:
|
||||
* - the in-app chords: Ctrl+=/-/0 (font size) and Ctrl+PgUp/PgDn (tab cycle),
|
||||
* under ANY extra modifiers — the in-app handlers ignore Shift/Alt, so a
|
||||
* global registration of Ctrl+Shift+= would shadow them;
|
||||
* - Ctrl+L, the panic lock, in every spelling a user or an older build can
|
||||
* produce ('ctrl+l', 'Ctrl+L', 'Control+L', 'CommandOrControl+L');
|
||||
* - everything else stays registrable, so the guard cannot grow into a
|
||||
* blanket refusal.
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/reserved-accelerators.mjs (must exit 0)
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { isReservedAccelerator, acceleratorParts } = require('./.reserved-accelerators.cjs')
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
const reserved = [
|
||||
// --- the panic lock, every spelling ---------------------------------------
|
||||
'Ctrl+L',
|
||||
'ctrl+l',
|
||||
'CTRL+L',
|
||||
'Control+L',
|
||||
'control+l',
|
||||
'CommandOrControl+L',
|
||||
'CmdOrCtrl+L',
|
||||
'CommandOrCtrl+L',
|
||||
'Ctrl + L', // Electron tolerates surrounding whitespace
|
||||
// --- font size (Ctrl = / - / 0) under extra modifiers ---------------------
|
||||
'Control+=',
|
||||
'Control+-',
|
||||
'Control+0',
|
||||
'Control+Shift+=',
|
||||
'Control+Shift+-',
|
||||
'Control+Shift+0',
|
||||
'Control+Alt+=',
|
||||
'Control+Alt+Shift+0',
|
||||
'ctrl+0',
|
||||
// --- tab cycling (Ctrl+PgUp/PgDn) ----------------------------------------
|
||||
'Control+PageUp',
|
||||
'Control+PageDown',
|
||||
'Control+Shift+PageUp',
|
||||
'Control+Alt+PageDown',
|
||||
'ctrl+pageup'
|
||||
]
|
||||
|
||||
const allowed = [
|
||||
// Not the panic chord: extra/missing modifiers change the meaning on purpose.
|
||||
'Alt+L',
|
||||
'Shift+L',
|
||||
'Super+L',
|
||||
'Control+Shift+L',
|
||||
'Control+Alt+L',
|
||||
'Control+Meta+L',
|
||||
// The font/tab keys WITHOUT Control belong to whatever is focused.
|
||||
'=',
|
||||
'-',
|
||||
'0',
|
||||
'Shift+=',
|
||||
'Alt+=',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'Shift+PageUp',
|
||||
// Other Control chords are free (they do not collide with an in-app binding).
|
||||
'Control+R',
|
||||
'Control+Shift+I',
|
||||
'Control+1',
|
||||
'Control+PageHome',
|
||||
'Control+F5',
|
||||
'Alt+Control+P',
|
||||
// Standalone function keys.
|
||||
'F5',
|
||||
'F12',
|
||||
'Control+F12'
|
||||
]
|
||||
|
||||
console.log('reserved (must be refused by both guards)')
|
||||
for (const a of reserved) ok(isReservedAccelerator(a), `reserved: ${JSON.stringify(a)}`)
|
||||
|
||||
console.log('allowed (must stay registrable)')
|
||||
for (const a of allowed) ok(!isReservedAccelerator(a), `allowed: ${JSON.stringify(a)}`)
|
||||
|
||||
console.log('modifier aliases fold to one canonical form')
|
||||
ok(acceleratorParts('Ctrl+L').join('+') === 'control+l', "'Ctrl' folds to 'control'")
|
||||
ok(acceleratorParts('CommandOrControl+L').join('+') === 'control+l', "'CommandOrControl' folds to 'control' (Windows-only app)")
|
||||
ok(acceleratorParts('CmdOrCtrl+L').join('+') === 'control+l', "'CmdOrCtrl' folds to 'control'")
|
||||
ok(acceleratorParts('Ctrl+Shift+P').join('+') === 'control+shift+p', 'Shift is folded to lower case and kept')
|
||||
ok(acceleratorParts('Super+L')[0] === 'super', 'Super is preserved (not an alias of Control)')
|
||||
|
||||
console.log('degenerate input does not throw and grants nothing')
|
||||
for (const a of ['', ' ', '+', 'Control+', '+L', 'nonsense']) {
|
||||
let threw = false
|
||||
let verdict
|
||||
try {
|
||||
verdict = isReservedAccelerator(a)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw && verdict === false, `junk input refused without throwing: ${JSON.stringify(a)}`)
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[reserved-accelerators] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[reserved-accelerators] ALL CHECKS PASSED (${reserved.length + allowed.length + 4 + 6} assertions)`)
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* SFTP timeout self-test (sftp-timeout.mjs).
|
||||
*
|
||||
* A half-dead SFTP channel — the peer is gone but no FIN was ever delivered,
|
||||
* which mobile / NAT'd links produce constantly — accepts a request and then
|
||||
* never calls back. ssh2 has no socket timeout of its own, so before this the
|
||||
* operation (and the spinner behind it) waited forever. `src/main/sftp.ts` now
|
||||
* bounds every operation, with two budgets because one number cannot serve
|
||||
* both: metadata round trips are milliseconds on a working link, while a 256KB
|
||||
* transfer chunk is legitimately seconds on a slow one.
|
||||
*
|
||||
* The test drives the real `withSftp` path with a fake ssh2 SFTP wrapper, so the
|
||||
* assertions are about the service's behavior: which budget applies, that a
|
||||
* timeout is transport-classified (channel evicted + ONE retry on a fresh
|
||||
* channel), that a late reply cannot resurrect an abandoned operation, and that
|
||||
* a slow-but-progressing transfer is never mistaken for a dead one.
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/sftp-timeout.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const sftp = await import('./.sftp-svc.mjs')
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
else passed += 1
|
||||
}
|
||||
|
||||
// ---- harness ----------------------------------------------------------------
|
||||
sftp.setSftpTimeouts({ op: 60, transfer: 200, open: 60 })
|
||||
// Local-path admission is exercised by its own test; this harness picks its own
|
||||
// temp paths and has no dialog to grant from.
|
||||
const root = mkdtempSync(join(tmpdir(), 'ot-sftp-timeout-'))
|
||||
sftp.setLocalPathPolicy({
|
||||
readSource: (p) => (typeof p === 'string' && p !== '' ? p : null),
|
||||
readDirectory: (d) => (typeof d === 'string' && d !== '' ? d : null),
|
||||
writeTarget: (dir, name) =>
|
||||
typeof dir === 'string' && dir !== '' && typeof name === 'string' && name !== ''
|
||||
? join(dir, name)
|
||||
: null
|
||||
})
|
||||
|
||||
/**
|
||||
* A fake ssh2 SFTPWrapper whose per-call behavior is scripted.
|
||||
* `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel
|
||||
* case), `'error'` (calls back with an error), or `'ok'` (calls back with
|
||||
* `result`). Every call is recorded so the test can assert on what was opened.
|
||||
*/
|
||||
const defaultStat = () => ({
|
||||
isDirectory: () => false,
|
||||
size: 10,
|
||||
mtime: 1_700_000_000,
|
||||
mode: 0o100644,
|
||||
uid: 1000,
|
||||
gid: 1000
|
||||
})
|
||||
|
||||
let calls
|
||||
let behaviour
|
||||
let openedChannels
|
||||
const makeWrapper = () => {
|
||||
const wrapper = {
|
||||
lstat: (p, cb) => dispatch('lstat', [p], cb, defaultStat()),
|
||||
readdir: (p, cb) => dispatch('readdir', [p], cb, ['a.txt', 'b.txt']),
|
||||
mkdir: (p, cb) => dispatch('mkdir', [p], cb, undefined, true),
|
||||
rename: (a, b, cb) => dispatch('rename', [a, b], cb, undefined, true),
|
||||
unlink: (p, cb) => dispatch('unlink', [p], cb, undefined, true),
|
||||
rmdir: (p, cb) => dispatch('rmdir', [p], cb, undefined, true),
|
||||
stat: (p, cb) => dispatch('stat', [p], cb, defaultStat()),
|
||||
open: (p, flags, cb) => dispatch('open', [p, flags], cb, Buffer.from('handle')),
|
||||
close: (h, cb) => dispatch('close', [h], cb, undefined, true),
|
||||
read: (h, buf, off, len, pos, cb) => {
|
||||
const verdict = behaviour('read', [h, off, len, pos])
|
||||
calls.push({ op: 'read', args: [off, len, pos] })
|
||||
if (verdict === 'silent') return
|
||||
if (verdict === 'error') return cb(new Error('read failed'))
|
||||
const answer = () => {
|
||||
// Two chunks, then EOF, so a healthy download terminates.
|
||||
if (pos >= 512) return cb(null, { bytesRead: 0, buffer: buf })
|
||||
buf.fill(0x41, off, off + 256)
|
||||
cb(null, { bytesRead: 256, buffer: buf })
|
||||
}
|
||||
// 'slow': answer after the metadata budget but inside the transfer one —
|
||||
// a slow link, which must NOT be treated as a dead channel.
|
||||
if (verdict === 'slow') setTimeout(answer, 120)
|
||||
else answer()
|
||||
},
|
||||
write: (h, buf, off, len, pos, cb) => dispatch('write', [h, len, pos], cb, undefined, true),
|
||||
end: () => calls.push({ op: 'end' }),
|
||||
on: () => wrapper
|
||||
}
|
||||
const dispatch = (op, args, cb, result, voidResult = false) => {
|
||||
calls.push({ op, args })
|
||||
const verdict = behaviour(op, args)
|
||||
if (verdict === 'silent') return
|
||||
if (verdict === 'error') return cb(new Error(`${op} failed`))
|
||||
cb(null, voidResult ? undefined : result)
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const reset = (impl) => {
|
||||
calls = []
|
||||
behaviour = impl ?? (() => 'ok')
|
||||
openedChannels = []
|
||||
}
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
|
||||
const expectReject = async (promise, label) => {
|
||||
try {
|
||||
await promise
|
||||
return { rejected: false, message: '' }
|
||||
} catch (err) {
|
||||
return { rejected: true, message: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1. a silent channel is bounded, not waited on forever -----------------
|
||||
console.log('a silent (half-dead) channel rejects instead of hanging')
|
||||
{
|
||||
reset(() => 'silent')
|
||||
sftp.closeSftp('s1')
|
||||
const started = Date.now()
|
||||
const r = await expectReject(sftp.listRemote('s1', '/home'), 'listRemote')
|
||||
const elapsed = Date.now() - started
|
||||
ok(r.rejected, 'listRemote rejects')
|
||||
ok(elapsed < 1500, `it rejected at the op budget, not later (${elapsed}ms)`)
|
||||
ok(r.message.length > 0, `the error carries a message for the user (${r.message})`)
|
||||
ok(!/^Failed/.test(r.message), 'the message is the app\'s own, not a raw ssh2 string')
|
||||
}
|
||||
|
||||
{
|
||||
// The retry is the point: a timeout is TRANSPORT-classified, so the possibly
|
||||
// dead channel is evicted and the operation is retried once on a fresh one.
|
||||
reset(() => 'silent')
|
||||
sftp.closeSftp('s2')
|
||||
await expectReject(sftp.listRemote('s2', '/home'), 'listRemote')
|
||||
ok(
|
||||
openedChannels.length === 2,
|
||||
`a timeout evicts the channel and retries exactly once on a fresh one (${openedChannels.length} channels opened)`
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
// A second timeout on the retry must surface, not loop.
|
||||
let opened = 0
|
||||
reset(() => 'silent')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
opened++
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('s3')
|
||||
const r = await expectReject(sftp.listRemote('s3', '/home'), 'listRemote')
|
||||
ok(r.rejected, 'a second consecutive timeout still rejects (no retry loop)')
|
||||
ok(opened === 2, `exactly two channel opens for one operation (${opened})`)
|
||||
// Restore the shared provider for later sections.
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- 2. metadata operations each have a bound ------------------------------
|
||||
console.log('every metadata operation is bounded')
|
||||
{
|
||||
const metadataOps = [
|
||||
['listRemote', () => sftp.listRemote('m', '/home'), 'readdir'],
|
||||
['mkdirRemote', () => sftp.mkdirRemote('m', '/home', 'dir'), 'mkdir'],
|
||||
['renameRemote', () => sftp.renameRemote('m', '/a', '/b'), 'rename'],
|
||||
['deleteRemote', () => sftp.deleteRemote('m', ['/a']), 'unlink']
|
||||
]
|
||||
for (const [label, run, firstOp] of metadataOps) {
|
||||
reset((op) => (op === firstOp || (label === 'listRemote' && op === 'readdir') ? 'silent' : 'ok'))
|
||||
sftp.closeSftp('m')
|
||||
const started = Date.now()
|
||||
const r = await expectReject(run(), label)
|
||||
const elapsed = Date.now() - started
|
||||
ok(r.rejected, `${label} rejects on a silent channel`)
|
||||
ok(elapsed < 1500, `${label} rejected at the budget (${elapsed}ms)`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('a server-side error is NOT retried (it means the channel is alive)')
|
||||
{
|
||||
reset(() => 'error')
|
||||
sftp.closeSftp('alive')
|
||||
const r = await expectReject(sftp.listRemote('alive', '/home'), 'listRemote')
|
||||
ok(r.rejected, 'a server-side failure rejects')
|
||||
ok(openedChannels.length === 1, `an error reply does not evict the channel (${openedChannels.length} open, expected 1)`)
|
||||
ok(calls.filter((c) => c.op === 'readdir').length === 1, 'and the operation was not retried')
|
||||
}
|
||||
|
||||
// ---- 3. the transfer budget is wider than the metadata one -----------------
|
||||
console.log('a slow but progressing download is not killed by the metadata budget')
|
||||
{
|
||||
const dir = mkdtempSync(join(root, 'dl-slow-'))
|
||||
// Each chunk answers after the metadata budget (60ms) but inside the transfer
|
||||
// budget (200ms): a slow link, not a dead one.
|
||||
reset((op) => (op === 'read' ? 'slow' : 'ok'))
|
||||
sftp.closeSftp('dl')
|
||||
const events = []
|
||||
await sftp.downloadRemote('dl', ['/remote/big.bin'], dir, (ch, payload) =>
|
||||
events.push(payload)
|
||||
)
|
||||
const done = await waitFor(() => events.some((e) => e.state === 'done' || e.state === 'error'), 3000)
|
||||
const final = events.at(-1)
|
||||
ok(done, 'the transfer settled')
|
||||
ok(final?.state === 'done', `a progressing transfer completes despite slow chunks (got '${final?.state}': ${final?.error ?? ''})`)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('a download whose channel goes silent IS bounded and reported')
|
||||
{
|
||||
const dir = mkdtempSync(join(root, 'dl-dead-'))
|
||||
reset(() => 'silent')
|
||||
sftp.closeSftp('dl-dead')
|
||||
const events = []
|
||||
await sftp.downloadRemote('dl-dead', ['/remote/big.bin'], dir, (ch, payload) => events.push(payload))
|
||||
const settled = await waitFor(() => events.some((e) => e.state === 'error'), 3000)
|
||||
ok(settled, 'the transfer reported a terminal error instead of hanging')
|
||||
const err = events.find((e) => e.state === 'error')
|
||||
ok(typeof err?.error === 'string' && err.error.length > 0, `the error is user-visible (${err?.error})`)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('an upload whose channel goes silent IS bounded and reported')
|
||||
{
|
||||
const dir = mkdtempSync(join(root, 'ul-dead-'))
|
||||
const src = join(dir, 'file.bin')
|
||||
writeFileSync(src, Buffer.alloc(600 * 1024, 0x42)) // >2 chunks, so writes happen
|
||||
reset((op) => (op === 'open' ? 'silent' : 'ok'))
|
||||
sftp.closeSftp('ul-dead')
|
||||
const events = []
|
||||
await sftp.uploadRemote('ul-dead', [src], '/remote', (ch, payload) => events.push(payload))
|
||||
const settled = await waitFor(() => events.some((e) => e.state === 'error'), 3000)
|
||||
ok(settled, 'the upload reported a terminal error')
|
||||
const err = events.find((e) => e.state === 'error')
|
||||
ok(typeof err?.error === 'string' && err.error.length > 0, `the error is user-visible (${err?.error})`)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log('a silent write chunk (open succeeded) is bounded too')
|
||||
{
|
||||
const dir = mkdtempSync(join(root, 'ul-silent-write-'))
|
||||
const src = join(dir, 'file.bin')
|
||||
writeFileSync(src, Buffer.alloc(600 * 1024, 0x42))
|
||||
reset((op) => (op === 'write' ? 'silent' : 'ok'))
|
||||
sftp.closeSftp('ul-silent-write')
|
||||
const events = []
|
||||
const started = Date.now()
|
||||
await sftp.uploadRemote('ul-silent-write', [src], '/remote', (ch, payload) => events.push(payload))
|
||||
const settled = await waitFor(() => events.some((e) => e.state === 'error'), 5000)
|
||||
const elapsed = Date.now() - started
|
||||
ok(settled, 'the upload reported a terminal error')
|
||||
ok(elapsed < 4000, `it gave up on the wider transfer budget rather than waiting forever (${elapsed}ms)`)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---- 4. a late reply cannot resurrect an abandoned operation ---------------
|
||||
console.log('a reply that arrives after the timeout is ignored, not applied')
|
||||
{
|
||||
let late
|
||||
let settle
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
// readdir answers only when the test releases it — well after the timeout.
|
||||
w.readdir = (p, cb) => {
|
||||
calls.push({ op: 'readdir', args: [p] })
|
||||
late = () => cb(null, ['too-late.txt'])
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('late')
|
||||
const r = await expectReject(sftp.listRemote('late', '/home'), 'listRemote')
|
||||
ok(r.rejected, 'the operation timed out')
|
||||
ok(typeof late === 'function', 'the fake held the reply')
|
||||
let threw = false
|
||||
try {
|
||||
late() // the abandoned callback fires now
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw, 'releasing the late reply does not throw (the race is already settled)')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
ok(true, 'the process stayed alive after a late reply (no unhandled rejection)')
|
||||
}
|
||||
|
||||
console.log('a rejection after the timeout is swallowed, not surfaced as a crash')
|
||||
{
|
||||
let late
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
w.mkdir = (p, cb) => {
|
||||
calls.push({ op: 'mkdir', args: [p] })
|
||||
late = () => cb(new Error('too late'))
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
let unhandled = 0
|
||||
function onUnhandled() {
|
||||
unhandled++
|
||||
}
|
||||
sftp.closeSftp('late2')
|
||||
await expectReject(sftp.mkdirRemote('late2', '/home', 'x'), 'mkdirRemote')
|
||||
late()
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
ok(unhandled === 0, `no unhandled rejection from the abandoned promise (${unhandled})`)
|
||||
// Restore the standard provider.
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- 5. the subsystem open is bounded too ----------------------------------
|
||||
console.log('a subsystem open that never answers is bounded (and retried once)')
|
||||
{
|
||||
let opens = 0
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
// Never calls back: the half-dead client that accepts the request and dies.
|
||||
sftp: () => {
|
||||
opens++
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('open-dead')
|
||||
const started = Date.now()
|
||||
const r = await expectReject(sftp.listRemote('open-dead', '/home'), 'listRemote')
|
||||
const elapsed = Date.now() - started
|
||||
ok(r.rejected, 'the operation rejects')
|
||||
ok(opens === 2, `the dead open was retried exactly once (${opens} attempts)`)
|
||||
ok(elapsed < 1500, `bounded by the open budget (${elapsed}ms)`)
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
console.log('a synchronous throw from client.sftp() is a rejection, not a crash')
|
||||
{
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: () => {
|
||||
throw new Error('socket already gone')
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('sync-throw')
|
||||
const r = await expectReject(sftp.listRemote('sync-throw', '/home'), 'listRemote')
|
||||
ok(r.rejected, 'the synchronous throw became a rejection')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
console.log('a missing session is refused without opening anything')
|
||||
{
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => undefined)
|
||||
sftp.closeSftp('gone')
|
||||
const r = await expectReject(sftp.listRemote('gone', '/home'), 'listRemote')
|
||||
ok(r.rejected, 'the operation rejects')
|
||||
ok(openedChannels.length === 0, 'no channel was opened for a missing session')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- 6. the budgets are the documented ones --------------------------------
|
||||
console.log('default budgets are sane relative to each other')
|
||||
{
|
||||
// The injected harness values are deliberately tiny; resetting them proves the
|
||||
// setter restores what later runs (and the app) expect, without depending on
|
||||
// internal constants.
|
||||
sftp.setSftpTimeouts({ op: 30_000, transfer: 60_000, open: 10_000 })
|
||||
reset(() => 'silent')
|
||||
sftp.closeSftp('budget')
|
||||
const started = Date.now()
|
||||
const probe = sftp.listRemote('budget', '/home')
|
||||
const settledEarly = await Promise.race([
|
||||
probe.then(() => true, () => true),
|
||||
new Promise((r) => setTimeout(() => r(false), 1500))
|
||||
])
|
||||
ok(!settledEarly, 'with the production metadata budget, a 1.5s wait does not time out (the budget is generous, not hair-trigger)')
|
||||
// Don't wait out the real 30s: abandon it and restore the harness budget.
|
||||
void probe.catch(() => undefined)
|
||||
sftp.setSftpTimeouts({ op: 60, transfer: 200, open: 60 })
|
||||
const elapsed = Date.now() - started
|
||||
ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`)
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
function waitFor(pred, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now()
|
||||
const tick = () => {
|
||||
if (pred()) return resolve(true)
|
||||
if (Date.now() - started > timeoutMs) return resolve(false)
|
||||
setTimeout(tick, 10)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[sftp-timeout] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[sftp-timeout] ALL CHECKS PASSED (${passed} assertions)`)
|
||||
process.exit(0)
|
||||
+378
-87
@@ -1,24 +1,61 @@
|
||||
/**
|
||||
* SSH loopback verification (M2). Pure Node ESM, no Electron.
|
||||
*
|
||||
* Spins up an in-process ssh2.Server (127.0.0.1, random port), then connects
|
||||
* with a plain ssh2.Client using the exact same connect parameters the main
|
||||
* process ssh service uses (host/port/username/password/keepalive/hostVerifier),
|
||||
* and confirms the full data plane round-trip:
|
||||
* Spins up an in-process ssh2.Server (127.0.0.1, random port) and drives the
|
||||
* SHIPPED connect path — `connectSsh` from src/main/ssh.ts, loaded through
|
||||
* tests/.ssh.cjs (build-bundles.cjs) — rather than a hand-copied ConnectConfig.
|
||||
* ssh.ts is deliberately Electron-free (`SshServiceDeps` injects the store, the
|
||||
* broadcast and the host-key prompt), so the real module can be exercised here
|
||||
* with no Chromium and no stubs. What that buys: the connect parameters, the
|
||||
* host-key verifier, the async prompt handshake and the auth gates are all the
|
||||
* code the app runs, not a copy that can silently drift from it.
|
||||
*
|
||||
* ready -> shell -> banner "LOOPBACK-OK" -> write data -> echo -> close
|
||||
* Covered:
|
||||
* - happy path: connect -> auth -> shell -> banner "LOOPBACK-OK" -> write ->
|
||||
* echo -> resize -> close, with the pinned key accepted without a prompt
|
||||
* - TOFU: an unknown key asks the renderer (promptHostKey), and accepting it
|
||||
* pins the key through knownHosts.accept before the handshake resumes
|
||||
* - a rejected key aborts the connect with a user-facing reason
|
||||
* - an unreadable store fails CLOSED without offering an accept
|
||||
* - a store whose check() throws is treated the same way (never 'new')
|
||||
* - a prompt nobody answers times out to a refusal
|
||||
* - an unreachable port is bounded by the connect timeout
|
||||
* - the auth gate: a connect-time password must NOT authenticate a
|
||||
* privateKey bookmark, a passphrase must NOT leak into password auth
|
||||
*
|
||||
* Run: `node tests/ssh-loopback.mjs` (must exit 0)
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/ssh-loopback.mjs (must exit 0)
|
||||
*/
|
||||
|
||||
import pkg from 'ssh2'
|
||||
const { Server, Client, utils } = pkg
|
||||
const { Server, utils } = pkg
|
||||
import { createHash } from 'crypto'
|
||||
import { createServer as createNetServer } from 'node:net'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const { connectSsh, resolveHostKey } = require_('./.ssh.cjs')
|
||||
|
||||
function fingerprintOf(key) {
|
||||
return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* `utils.generateKeyPairSync().public` is the OpenSSH TEXT form
|
||||
* (`ssh-ed25519 AAAA…`), while ssh2's `hostVerifier` receives the raw wire
|
||||
* blob — the base64 payload of that text, decoded. Converting here keeps the
|
||||
* fingerprint assertions about the key the client was actually offered.
|
||||
*/
|
||||
const wireKeyOf = (publicKey) => Buffer.from(String(publicKey).trim().split(/\s+/)[1], 'base64')
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
else passed += 1
|
||||
}
|
||||
|
||||
const fail = (msg) => {
|
||||
console.error(`FAIL: ${msg}`)
|
||||
process.exit(1)
|
||||
@@ -43,6 +80,7 @@ function newHostKey() {
|
||||
const serverKey = newHostKey()
|
||||
let serverPort = 0
|
||||
let seenWindowChange = { cols: 0, rows: 0 }
|
||||
let serverHostKey = null
|
||||
|
||||
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
|
||||
client.on('authentication', (ctx) => {
|
||||
@@ -76,7 +114,10 @@ const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
|
||||
})
|
||||
})
|
||||
|
||||
client.on('error', (err) => console.error('[server] client error', err.message))
|
||||
client.on('error', (err) => {
|
||||
// Rejected keys and refused auths are expected; not a harness failure.
|
||||
void err
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -88,91 +129,341 @@ await new Promise((resolve, reject) => {
|
||||
})
|
||||
console.log(`[loopback] ssh server listening on 127.0.0.1:${serverPort}`)
|
||||
|
||||
// ---- 2. Connect with the same params the main ssh service uses --------------
|
||||
const client = new Client()
|
||||
let bannerSeen = false
|
||||
let echoed = false
|
||||
let output = ''
|
||||
let verifyCalls = 0
|
||||
let resolveDone
|
||||
const done = new Promise((r) => (resolveDone = r))
|
||||
const timer = setTimeout(() => {
|
||||
fail(`timed out (client connected: ${client._stream ? 'yes' : 'no'})`)
|
||||
}, 10000)
|
||||
// ---- 2. Sanity: the bundle really is the shipped module --------------------
|
||||
{
|
||||
ok(
|
||||
/^SHA256:[A-Za-z0-9+/]{43}$/.test(fingerprintOf(wireKeyOf(serverKey.public))),
|
||||
'the harness fingerprint matches the OpenSSH SHA256 form (43 chars, no padding)'
|
||||
)
|
||||
ok(typeof connectSsh === 'function', 'connectSsh was loaded from the real src/main/ssh.ts bundle')
|
||||
ok(typeof resolveHostKey === 'function', 'resolveHostKey was loaded from the same bundle')
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
console.log('[loopback] client ready')
|
||||
client.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => {
|
||||
if (err) return fail(`shell error: ${err.message}`)
|
||||
console.log('[loopback] shell open')
|
||||
let sentInput = false
|
||||
let sentExit = false
|
||||
// ---- 3. deps ---------------------------------------------------------------
|
||||
/** Records everything the service asks the outside world for. */
|
||||
const makeDeps = (over = {}) => {
|
||||
const seen = {
|
||||
prompts: [],
|
||||
accepted: [],
|
||||
touched: [],
|
||||
broadcasts: []
|
||||
}
|
||||
return {
|
||||
seen,
|
||||
deps: {
|
||||
connections: {
|
||||
getSecret: () => over.secret,
|
||||
touch: (id) => seen.touched.push(id)
|
||||
},
|
||||
knownHosts: {
|
||||
check: over.check ?? (() => ({ status: 'match' })),
|
||||
accept: (host, port, key, fingerprint) => seen.accepted.push({ host, port, fingerprint })
|
||||
},
|
||||
broadcast: (channel, ...args) => seen.broadcasts.push({ channel, args }),
|
||||
promptHostKey: (prompt) => seen.prompts.push(prompt),
|
||||
timeoutMs: over.timeoutMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.on('data', (d) => {
|
||||
const chunk = d.toString('utf8')
|
||||
output += chunk
|
||||
if (output.includes('LOOPBACK-OK') && !sentInput) {
|
||||
bannerSeen = true
|
||||
console.log('[loopback] banner received')
|
||||
// exercise resize while the session is live
|
||||
stream.setWindow(40, 120, 0, 0)
|
||||
sentInput = true
|
||||
stream.write('hello loopback\n')
|
||||
}
|
||||
if (output.includes('hello loopback') && echoed === false) {
|
||||
echoed = true
|
||||
console.log('[loopback] echo received')
|
||||
}
|
||||
if (bannerSeen && echoed && !sentExit) {
|
||||
sentExit = true
|
||||
stream.write('exit\n')
|
||||
}
|
||||
})
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timer)
|
||||
console.log('[loopback] stream closed')
|
||||
client.end()
|
||||
resolveDone()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
client.on('error', (err) => fail(`client error: ${err.message}`))
|
||||
|
||||
// hostVerifier mirrors ssh.ts (SHA256 fingerprint; loopback accepts the key)
|
||||
client.connect({
|
||||
const connection = (over = {}) => ({
|
||||
id: 'loopback',
|
||||
name: 'loopback',
|
||||
host: '127.0.0.1',
|
||||
port: serverPort,
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
keepaliveInterval: 0,
|
||||
hostVerifier: (hostKey, verify) => {
|
||||
verifyCalls++
|
||||
const fp = fingerprintOf(hostKey)
|
||||
console.log(`[loopback] hostVerifier called (${verifyCalls}), fp=${fp}`)
|
||||
verify(true)
|
||||
}
|
||||
auth: 'password',
|
||||
askPasswordAtConnect: false,
|
||||
askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 0,
|
||||
createdAt: Date.now(),
|
||||
savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false },
|
||||
...over
|
||||
})
|
||||
|
||||
// ---- 3. Assertions ----------------------------------------------------------
|
||||
await done
|
||||
srv.close()
|
||||
|
||||
const checks = [
|
||||
['client reached ready', bannerSeen],
|
||||
['banner LOOPBACK-OK received', bannerSeen],
|
||||
['typed data echoed back', echoed],
|
||||
['host key verified exactly once', verifyCalls === 1],
|
||||
['resize propagated to server (40x120)', seenWindowChange.cols === 120 && seenWindowChange.rows === 40]
|
||||
]
|
||||
|
||||
let ok = true
|
||||
for (const [label, pass] of checks) {
|
||||
console.log(`[loopback] ${pass ? 'PASS' : 'FAIL'}: ${label}`)
|
||||
if (!pass) ok = false
|
||||
/** Run a connect and report whether it resolved. */
|
||||
const tryConnect = async (conn, secretOverride, over) => {
|
||||
const { seen, deps } = makeDeps({ secret: 'test', ...over })
|
||||
try {
|
||||
const handle = await connectSsh(conn, secretOverride, deps)
|
||||
return { handle, seen }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err), seen }
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) fail('one or more checks failed')
|
||||
console.log('[loopback] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
// ---- 4. happy path: pinned key, no prompt ----------------------------------
|
||||
console.log('happy path: connect, auth, shell, data plane, resize')
|
||||
{
|
||||
const { handle, error, seen } = await tryConnect(connection(), undefined)
|
||||
if (error) fail(`connect failed: ${error}`)
|
||||
ok(handle?.id !== undefined, 'the service resolved with a session id')
|
||||
ok(handle.client !== undefined && typeof handle.stream?.write === 'function', 'the handle carries the client and the shell stream')
|
||||
ok(handle.exitCode === 0, 'a fresh session starts with exit code 0')
|
||||
ok(seen.prompts.length === 0, 'a pinned (status match) key is accepted without prompting the user')
|
||||
ok(seen.touched.includes('loopback'), 'the bookmark is touched (lastConnectedAt) on a successful connect')
|
||||
ok(seen.accepted.length === 0, 'an already-pinned key is not re-pinned')
|
||||
|
||||
const output = await new Promise((resolve, reject) => {
|
||||
let text = ''
|
||||
let sentInput = false
|
||||
const timer = setTimeout(() => reject(new Error(`timed out, saw: ${JSON.stringify(text)}`)), 8000)
|
||||
handle.stream.on('data', (d) => {
|
||||
text += d.toString('utf8')
|
||||
if (text.includes('LOOPBACK-OK') && !sentInput) {
|
||||
sentInput = true
|
||||
// exercise resize while the session is live
|
||||
handle.stream.setWindow(40, 120, 0, 0)
|
||||
handle.stream.write('hello loopback\n')
|
||||
}
|
||||
if (text.includes('hello loopback') && text.includes('LOOPBACK-OK')) {
|
||||
clearTimeout(timer)
|
||||
resolve(text)
|
||||
}
|
||||
})
|
||||
handle.stream.on('error', (e) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
})
|
||||
})
|
||||
ok(output.includes('LOOPBACK-OK'), 'the server banner arrived ("LOOPBACK-OK")')
|
||||
ok(output.includes('hello loopback'), 'typed data was echoed back through the real shell')
|
||||
|
||||
// Resize reached the server (the server only records the last window-change).
|
||||
const resized = await new Promise((resolve) => {
|
||||
const started = Date.now()
|
||||
const tick = () => {
|
||||
if (seenWindowChange.cols === 120 && seenWindowChange.rows === 40) return resolve(true)
|
||||
if (Date.now() - started > 3000) return resolve(false)
|
||||
setTimeout(tick, 20)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
ok(resized, `resize propagated to the server (40x120, saw ${JSON.stringify(seenWindowChange)})`)
|
||||
|
||||
await new Promise((resolve) => {
|
||||
handle.stream.on('close', resolve)
|
||||
handle.stream.write('exit\n')
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
try {
|
||||
handle.client.end()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 5. TOFU: unknown key is prompted, accepted, then pinned ---------------
|
||||
console.log('TOFU: an unknown host key is prompted and pinned on accept')
|
||||
{
|
||||
const check = () => ({ status: 'new' })
|
||||
const { seen, deps } = makeDeps({ secret: 'test', check })
|
||||
const pending = connectSsh(connection(), undefined, deps)
|
||||
|
||||
// The handshake is paused inside hostVerifier until we answer.
|
||||
const prompt = await waitFor(() => seen.prompts[0], 5000)
|
||||
ok(prompt !== undefined, 'the renderer was asked to decide on the new key')
|
||||
ok(prompt?.host === '127.0.0.1' && prompt?.port === serverPort, 'the prompt names the host and port')
|
||||
ok(prompt?.reason === 'new', "the prompt reason is 'new' for an unknown key")
|
||||
ok(prompt?.fingerprint === fingerprintOf(wireKeyOf(serverKey.public)), 'the prompt carries the SHA256 fingerprint of the key actually offered')
|
||||
ok(typeof prompt?.promptId === 'string' && prompt.promptId.length > 0, 'the prompt carries an id to answer with')
|
||||
|
||||
// Nothing may be pinned before the user decides.
|
||||
ok(seen.accepted.length === 0, 'the key is not pinned while the decision is outstanding')
|
||||
|
||||
resolveHostKey(prompt.promptId, 'accept')
|
||||
const handle = await pending
|
||||
ok(handle?.id !== undefined, 'the connect resumed and succeeded after the accept')
|
||||
ok(seen.accepted.length === 1, 'the accepted key was pinned exactly once')
|
||||
ok(seen.accepted[0].fingerprint === fingerprintOf(wireKeyOf(serverKey.public)), 'the pinned fingerprint is the one shown to the user')
|
||||
ok(seen.accepted[0].host === '127.0.0.1' && seen.accepted[0].port === serverPort, 'the key is pinned for the right host:port')
|
||||
try {
|
||||
handle.client.end()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
console.log('an answer that arrives after the handshake failed is ignored (not pinned)')
|
||||
{
|
||||
// The prompt is answered, but the transport dies first: pinning then would
|
||||
// record trust for a connection that never completed.
|
||||
const check = () => ({ status: 'new' })
|
||||
const { seen, deps } = makeDeps({ secret: 'test', check })
|
||||
const conn = connection({ port: 1 }) // nothing listens there
|
||||
const pending = connectSsh(conn, undefined, deps)
|
||||
// Wait for the transport to fail (or for the prompt, whichever comes first).
|
||||
const settled = await Promise.race([
|
||||
pending.then(() => 'resolved', () => 'rejected'),
|
||||
new Promise((r) => setTimeout(() => r('pending'), 3000))
|
||||
])
|
||||
ok(settled === 'rejected' || settled === 'pending', `the dead-port connect did not succeed (${settled})`)
|
||||
const prompt = seen.prompts[0]
|
||||
if (prompt) {
|
||||
resolveHostKey(prompt.promptId, 'accept')
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
ok(seen.accepted.length === 0, 'no fingerprint was pinned for a connect that never completed')
|
||||
await pending.catch(() => undefined)
|
||||
}
|
||||
|
||||
// ---- 6. reject / unreadable / throwing store -------------------------------
|
||||
console.log('a rejected key aborts the connect with a user-facing reason')
|
||||
{
|
||||
const check = () => ({ status: 'new' })
|
||||
const { seen, deps } = makeDeps({ secret: 'test', check })
|
||||
const pending = connectSsh(connection(), undefined, deps)
|
||||
const prompt = await waitFor(() => seen.prompts[0], 5000)
|
||||
ok(prompt !== undefined, 'the user was prompted')
|
||||
resolveHostKey(prompt.promptId, 'reject')
|
||||
const r = await pending.then(() => null, (e) => e.message)
|
||||
ok(typeof r === 'string', 'the connect was refused')
|
||||
ok(r.includes('127.0.0.1') && r.includes(String(serverPort)), `the refusal names the host:port (${r})`)
|
||||
ok(seen.accepted.length === 0, 'nothing was pinned for a rejected key')
|
||||
}
|
||||
|
||||
console.log('an unreadable store fails CLOSED without offering an accept')
|
||||
{
|
||||
const check = () => ({ status: 'unreadable' })
|
||||
const { seen } = await tryConnect(connection(), undefined, { check })
|
||||
ok(seen.prompts.length === 0, 'the user is NOT offered an accept when the store cannot be read')
|
||||
ok(seen.accepted.length === 0, 'nothing is pinned into a store we failed to load')
|
||||
}
|
||||
|
||||
console.log('a store whose check() throws is treated as unreadable, never as new')
|
||||
{
|
||||
const check = () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
const { error, seen } = await tryConnect(connection(), undefined, { check })
|
||||
ok(typeof error === 'string', 'the connect failed')
|
||||
ok(seen.prompts.length === 0, 'a throwing store does NOT become a prompt (falling back to "new" would rewrite the store)')
|
||||
ok(seen.accepted.length === 0, 'and nothing is pinned')
|
||||
}
|
||||
|
||||
console.log('an accept that fails to persist refuses the connection')
|
||||
{
|
||||
const check = () => ({ status: 'new' })
|
||||
const { seen, deps } = makeDeps({ secret: 'test', check })
|
||||
deps.knownHosts.accept = () => {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
const pending = connectSsh(connection(), undefined, deps)
|
||||
const prompt = await waitFor(() => seen.prompts[0], 5000)
|
||||
resolveHostKey(prompt.promptId, 'accept')
|
||||
const r = await pending.then(() => null, (e) => e.message)
|
||||
ok(typeof r === 'string', 'the connect was refused rather than proceeding unpinned')
|
||||
ok(r.includes('127.0.0.1'), `the refusal names the host (${r})`)
|
||||
}
|
||||
|
||||
// ---- 7. timeouts -----------------------------------------------------------
|
||||
console.log('a prompt nobody answers times out into a refusal')
|
||||
{
|
||||
const check = () => ({ status: 'new' })
|
||||
const started = Date.now()
|
||||
const { error, seen } = await tryConnect(connection(), undefined, {
|
||||
check,
|
||||
timeoutMs: { prompt: 120, connect: 5000 }
|
||||
})
|
||||
const elapsed = Date.now() - started
|
||||
ok(seen.prompts.length === 1, 'the user was asked')
|
||||
ok(typeof error === 'string', 'the unanswered prompt became a refusal')
|
||||
ok(elapsed < 3000, `the prompt budget decided it, not the connect budget (${elapsed}ms)`)
|
||||
}
|
||||
|
||||
console.log('an unreachable port is bounded by the connect timeout')
|
||||
{
|
||||
// A TCP server that accepts the connection and then says nothing: the SSH
|
||||
// handshake never starts, which is the shape a dropped firewall produces.
|
||||
const sockets = new Set()
|
||||
const blackhole = createNetServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.on('close', () => sockets.delete(socket))
|
||||
socket.on('error', () => undefined)
|
||||
})
|
||||
await new Promise((r) => blackhole.listen(0, '127.0.0.1', r))
|
||||
const port = blackhole.address().port
|
||||
const started = Date.now()
|
||||
const { error } = await tryConnect(connection({ port }), undefined, {
|
||||
timeoutMs: { prompt: 1000, connect: 250 }
|
||||
})
|
||||
const elapsed = Date.now() - started
|
||||
ok(typeof error === 'string', 'the stalled handshake was refused')
|
||||
ok(error.includes('127.0.0.1') && error.includes(String(port)), `the refusal names the host:port (${error})`)
|
||||
ok(elapsed < 3000, `it gave up at the connect budget (${elapsed}ms)`)
|
||||
// `close()` only calls back once every accepted socket is gone, and the
|
||||
// abandoned client left one behind — drop them explicitly.
|
||||
for (const socket of sockets) socket.destroy()
|
||||
await new Promise((r) => blackhole.close(r))
|
||||
}
|
||||
|
||||
// ---- 8. auth gates ---------------------------------------------------------
|
||||
console.log('the auth gates hold')
|
||||
{
|
||||
// A stored password on a privateKey bookmark must not be offered.
|
||||
const { error } = await tryConnect(connection({ auth: 'privateKey' }), undefined)
|
||||
ok(typeof error === 'string', 'a key-auth bookmark with only a stored password cannot authenticate')
|
||||
|
||||
// A connect-time typed password must not upgrade a key-auth bookmark either.
|
||||
const { error: e2 } = await tryConnect(connection({ auth: 'privateKey' }), { password: 'test' })
|
||||
ok(typeof e2 === 'string', 'a typed password does not authenticate a key-auth bookmark (the gate above)')
|
||||
|
||||
// A typed password DOES authenticate a password bookmark (secretOverride path).
|
||||
const { handle, seen } = await tryConnect(connection(), { password: 'test' })
|
||||
ok(handle?.id !== undefined, 'a typed connect-time password authenticates a password bookmark')
|
||||
ok(seen.touched.includes('loopback'), 'and the session is a normal successful connect')
|
||||
try {
|
||||
handle.client.end()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
|
||||
// Wrong password is refused by the server, and reported as a connect failure.
|
||||
const { error: e3 } = await tryConnect(connection(), undefined, { secret: 'wrong' })
|
||||
ok(typeof e3 === 'string' && e3.includes('127.0.0.1'), `a wrong password is reported as a connect failure (${e3})`)
|
||||
}
|
||||
|
||||
console.log('an unparseable private key is refused at connect, not thrown into the void')
|
||||
{
|
||||
const { error } = await tryConnect(connection({ auth: 'privateKey' }), undefined, {
|
||||
secret: 'not a key'
|
||||
})
|
||||
ok(typeof error === 'string', 'the connect was refused')
|
||||
ok(error.includes('127.0.0.1'), `the refusal names the host (${error})`)
|
||||
}
|
||||
|
||||
// ---- 9. broadcasting --------------------------------------------------------
|
||||
console.log('the renderer prompt goes out over the injected broadcast surface')
|
||||
{
|
||||
const check = () => ({ status: 'new' })
|
||||
const { seen, deps } = makeDeps({ secret: 'test', check })
|
||||
const pending = connectSsh(connection(), undefined, deps)
|
||||
const prompt = await waitFor(() => seen.prompts[0], 5000)
|
||||
ok(prompt !== undefined, 'promptHostKey was called through deps (the app wires it to broadcast)')
|
||||
resolveHostKey(prompt.promptId, 'accept')
|
||||
await pending.catch(() => undefined)
|
||||
ok(seen.broadcasts.length === 0, 'the service itself does not broadcast (pty.ts owns the session events)')
|
||||
}
|
||||
|
||||
// ---- teardown ---------------------------------------------------------------
|
||||
srv.close()
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[loopback] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[loopback] ALL CHECKS PASSED (${passed} assertions)`)
|
||||
process.exit(0)
|
||||
|
||||
function waitFor(pred, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now()
|
||||
const tick = () => {
|
||||
const value = pred()
|
||||
if (value !== undefined) return resolve(value)
|
||||
if (Date.now() - started > timeoutMs) return resolve(undefined)
|
||||
setTimeout(tick, 10)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Update-service self-test (updater-fallback.mjs).
|
||||
*
|
||||
* src/main/updater.ts owns the two-feed strategy that keeps update checks
|
||||
* working for users behind (and without) a proxy. Everything in it that used to
|
||||
* be untestable — electron-updater and electron's session/net — is stubbed:
|
||||
*
|
||||
* - `electron-updater` is aliased to tests/electron-updater-stub.cjs, driven
|
||||
* through `globalThis.__otAutoUpdater`
|
||||
* - electron's `session.fromPartition(...).fetch` goes through
|
||||
* `globalThis.__otFetch`, and each session records its `setProxy` calls
|
||||
*
|
||||
* What is pinned here:
|
||||
* - the GitHub probe gates the feed: reachable -> GitHub first; unreachable
|
||||
* (thrown, non-OK, or TIMED OUT) -> straight to Gitea, and the updater is
|
||||
* never pointed at GitHub
|
||||
* - the probe timeout really fires (a fetch that never resolves is abandoned
|
||||
* at the budget, not waited on)
|
||||
* - a check that throws on GitHub falls back to Gitea, and BOTH error
|
||||
* messages reach the user when Gitea fails too
|
||||
* - the overall check budget rejects a check that never settles, and the
|
||||
* state lands on 'error' — never stuck on 'checking'
|
||||
* - the feed choice is per attempt: a transient GitHub failure does not pin
|
||||
* the next check to Gitea
|
||||
* - proxy modes: Gitea forces `direct`, GitHub uses `system` (the whole point
|
||||
* of the two-feed split)
|
||||
* - dev builds never check (state 'dev'), and a packaged build's changelog
|
||||
* fetch falls back through the three sources
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/updater-fallback.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// ---- stub control -----------------------------------------------------------
|
||||
// Must be installed BEFORE the bundle is required (the stub reads globals).
|
||||
const ctl = {
|
||||
checkForUpdates: undefined,
|
||||
downloadUpdate: undefined,
|
||||
quitAndInstallCalls: [],
|
||||
feeds: [],
|
||||
proxies: []
|
||||
}
|
||||
globalThis.__otAutoUpdater = ctl
|
||||
|
||||
let fetchImpl = undefined
|
||||
globalThis.__otFetch = (url, init) => {
|
||||
if (!fetchImpl) {
|
||||
return Promise.resolve({ ok: false, status: 404, text: async () => '', json: async () => [] })
|
||||
}
|
||||
return fetchImpl(url, init)
|
||||
}
|
||||
|
||||
const stub = require('./electron-stub.cjs')
|
||||
stub.__setPackaged(false)
|
||||
|
||||
const updater = require('./.updater.cjs')
|
||||
const { Ipc } = require('./.ipc-channels.cjs')
|
||||
|
||||
let failed = 0
|
||||
let passed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
else passed += 1
|
||||
}
|
||||
|
||||
// Shrink every budget so timeout paths run in milliseconds, not minutes.
|
||||
updater.setUpdateTimeouts({ probe: 60, check: 120, fetch: 60 })
|
||||
|
||||
/**
|
||||
* The service's own timers (`withTimeout`, `AbortSignal.timeout`) are unref'd on
|
||||
* purpose — a pending update check must never keep the app alive. In a test
|
||||
* process that means Node can decide to exit while an awaited check is still
|
||||
* pending, which shows up as "unsettled top-level await". This ref'd interval
|
||||
* keeps the loop turning for the duration of the run.
|
||||
*/
|
||||
const keepAlive = setInterval(() => {}, 1000)
|
||||
|
||||
// The channels are registered by registerUpdateIpc(); the stub records them.
|
||||
updater.registerUpdateIpc()
|
||||
const handlers = stub.__handlers
|
||||
const call = (channel, ...args) => handlers.get(channel)({ senderFrame: { url: 'x' } }, ...args)
|
||||
|
||||
// The module-level `state` is shared by every section below, so the birth state
|
||||
// is asserted here — before any check has had a chance to move it.
|
||||
const birthState = await call(Ipc.UPDATE_STATE_GET)
|
||||
|
||||
const reset = () => {
|
||||
ctl.feeds.length = 0
|
||||
ctl.proxies.length = 0
|
||||
ctl.quitAndInstallCalls.length = 0
|
||||
ctl.checkForUpdates = undefined
|
||||
fetchImpl = undefined
|
||||
stub.__sessions.clear()
|
||||
}
|
||||
const okResponse = (body = '') => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => body,
|
||||
json: async () => JSON.parse(body || '[]')
|
||||
})
|
||||
const errResponse = (status = 500) => ({
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => '',
|
||||
json: async () => []
|
||||
})
|
||||
/**
|
||||
* A fetch that never settles on its own — the half-dead channel the timeouts
|
||||
* exist for. It holds the event loop open with a ref'd timer because
|
||||
* `AbortSignal.timeout`'s internal timer is unref'd: without it Node would exit
|
||||
* before the abort fires and the test would look like a hang.
|
||||
*/
|
||||
const neverSettles = (url, init) =>
|
||||
new Promise((_, reject) => {
|
||||
const keepAlive = setTimeout(() => reject(new Error('harness: fetch never settled')), 30_000)
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(keepAlive)
|
||||
reject(new Error('aborted'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
|
||||
const GitHubProbeHost = 'api.github.com'
|
||||
const isProbe = (url) => String(url).includes(GitHubProbeHost)
|
||||
let probeCalls = 0
|
||||
const feedFor = (provider, feeds) => feeds.find((f) => f.provider === provider)
|
||||
const lastFeed = () => ctl.feeds[ctl.feeds.length - 1]
|
||||
/**
|
||||
* Which feed the updater was last pointed at, in the terms the service uses:
|
||||
* electron-updater's provider name is 'github' for the GitHub feed and
|
||||
* 'generic' for the domestic Gitea package channel.
|
||||
*/
|
||||
const lastFeedName = () => (lastFeed()?.provider === 'github' ? 'github' : 'gitea')
|
||||
const proxyFor = (name) => stub.__sessions.get(name)?.proxyCalls ?? []
|
||||
|
||||
// ---- 1. initial state + dev build ------------------------------------------
|
||||
console.log('a freshly started service')
|
||||
{
|
||||
ok(birthState.status === 'idle', `the service starts idle (got '${birthState.status}')`)
|
||||
ok(birthState.currentVersion === '0.0.0-stub', 'the state always carries the running version')
|
||||
ok(birthState.version === undefined, 'no version is claimed before a check')
|
||||
ok(birthState.error === undefined, 'no error is claimed before a check')
|
||||
ok(birthState.percent === undefined, 'no download percent before a download')
|
||||
}
|
||||
|
||||
console.log('dev build (not packaged)')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(false)
|
||||
let checked = false
|
||||
ctl.checkForUpdates = async () => {
|
||||
checked = true
|
||||
return null
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(state.status === 'dev', `a dev build reports 'dev' (got '${state.status}')`)
|
||||
ok(!checked, 'the updater is never asked to check in a dev build')
|
||||
ok(ctl.feeds.length === 0, 'no feed is configured in a dev build')
|
||||
await call(Ipc.UPDATE_DOWNLOAD)
|
||||
ok(true, 'download in a dev build is a no-op, not a crash')
|
||||
}
|
||||
|
||||
// ---- 2. probe reachable -> GitHub first ------------------------------------
|
||||
console.log('probe succeeds: GitHub is the feed')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
probeCalls = 0
|
||||
fetchImpl = (url) => {
|
||||
if (isProbe(url)) {
|
||||
probeCalls++
|
||||
return Promise.resolve(okResponse('{"tag_name":"v1"}'))
|
||||
}
|
||||
return Promise.resolve(okResponse('[]'))
|
||||
}
|
||||
let seen = ''
|
||||
ctl.checkForUpdates = async () => {
|
||||
seen = lastFeedName()
|
||||
return null
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(probeCalls === 1, 'the probe ran exactly once')
|
||||
ok(seen === 'github', `the check ran against the GitHub feed (got '${seen}')`)
|
||||
ok(feedFor('github', ctl.feeds) !== undefined, 'the GitHub feed was configured')
|
||||
ok(feedFor('github', ctl.feeds).owner === 'billowliu2', 'the GitHub feed carries the repo owner')
|
||||
ok(proxyFor('openterminal-github-probe').some((p) => p.mode === 'system'), 'the probe used the system proxy')
|
||||
ok(state.status === 'checking' || state.status === 'error' || state.status === 'idle', `state is a known value (${state.status})`)
|
||||
}
|
||||
|
||||
// ---- 3. probe fails -> straight to Gitea -----------------------------------
|
||||
console.log('probe failures fall through to Gitea without touching GitHub')
|
||||
const probeFailures = [
|
||||
['non-OK response (404 from a blocked/mirrored host)', () => Promise.resolve(errResponse(404))],
|
||||
['a thrown network error', () => Promise.reject(new Error('ENOTFOUND api.github.com'))],
|
||||
['a fetch that never settles (times out at the budget)', neverSettles]
|
||||
]
|
||||
for (const [label, impl] of probeFailures) {
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
probeCalls = 0
|
||||
fetchImpl = (url, init) => {
|
||||
if (isProbe(url)) {
|
||||
probeCalls++
|
||||
return impl(url, init)
|
||||
}
|
||||
return Promise.resolve(okResponse('[]'))
|
||||
}
|
||||
let seen = ''
|
||||
ctl.checkForUpdates = async () => {
|
||||
seen = lastFeedName()
|
||||
return null
|
||||
}
|
||||
const started = Date.now()
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
const elapsed = Date.now() - started
|
||||
ok(probeCalls === 1, `${label}: the probe was attempted once`)
|
||||
ok(seen === 'gitea', `${label}: the check went straight to Gitea (got '${seen}')`)
|
||||
ok(feedFor('github', ctl.feeds) === undefined, `${label}: the GitHub feed was never configured`)
|
||||
ok(feedFor('generic', ctl.feeds) !== undefined, `${label}: the Gitea feed is the generic provider`)
|
||||
if (label.includes('times out')) {
|
||||
ok(elapsed < 2000, `${label}: the abandoned probe was refused at the budget, not waited on (${elapsed}ms)`)
|
||||
} else {
|
||||
ok(elapsed < 2000, `${label}: resolved promptly (${elapsed}ms)`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('proxy modes: Gitea is forced direct, GitHub uses the system proxy')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]')))
|
||||
ctl.checkForUpdates = async () => {
|
||||
// netSession.setProxy is what useFeed() calls on the updater itself.
|
||||
return null
|
||||
}
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
ok(
|
||||
ctl.proxies.some((p) => p.mode === 'system'),
|
||||
'the GitHub attempt set the updater session to the system proxy'
|
||||
)
|
||||
|
||||
reset()
|
||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
||||
ctl.checkForUpdates = async () => null
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
ok(
|
||||
ctl.proxies.some((p) => p.mode === 'direct'),
|
||||
'the Gitea attempt set the updater session to direct (a system proxy breaks it)'
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 4. GitHub check fails -> Gitea fallback, both errors reported ---------
|
||||
console.log('a GitHub check failure falls back to Gitea')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]')))
|
||||
const attempted = []
|
||||
ctl.checkForUpdates = async () => {
|
||||
attempted.push(lastFeedName())
|
||||
if (attempted.length === 1) throw new Error('github exploded')
|
||||
return null
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(attempted.join(',') === 'github,gitea', `both feeds were tried in order (${attempted.join(',')})`)
|
||||
ok(feedFor('generic', ctl.feeds) !== undefined, 'the Gitea feed was configured for the fallback')
|
||||
ok(state.status !== 'error', `the fallback succeeded, so no error state (got '${state.status}')`)
|
||||
}
|
||||
|
||||
console.log('both feeds failing reports BOTH reasons to the user')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]')))
|
||||
const attempted = []
|
||||
ctl.checkForUpdates = async () => {
|
||||
attempted.push(lastFeedName())
|
||||
throw new Error(attempted.length === 1 ? 'github exploded' : 'gitea exploded')
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(attempted.join(',') === 'github,gitea', 'both feeds were attempted')
|
||||
ok(state.status === 'error', `the state is 'error' (got '${state.status}')`)
|
||||
ok(typeof state.error === 'string' && state.error.includes('github exploded'), 'the GitHub reason is reported')
|
||||
ok(typeof state.error === 'string' && state.error.includes('gitea exploded'), 'the Gitea reason is reported')
|
||||
}
|
||||
|
||||
console.log('Gitea-only failure reports its own reason')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
||||
ctl.checkForUpdates = async () => {
|
||||
throw new Error('gitea exploded')
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(state.status === 'error', 'the state is error')
|
||||
ok(state.error === 'gitea exploded', `the Gitea reason is the error (got ${JSON.stringify(state.error)})`)
|
||||
}
|
||||
|
||||
// ---- 5. the overall check budget -------------------------------------------
|
||||
console.log('a check that never settles is bounded by the overall budget')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = () => Promise.resolve(errResponse(503)) // probe fails -> Gitea directly
|
||||
ctl.checkForUpdates = () => new Promise(() => {}) // never settles
|
||||
const started = Date.now()
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
const elapsed = Date.now() - started
|
||||
ok(elapsed < 3000, `it gave up at the budget instead of hanging (${elapsed}ms)`)
|
||||
ok(state.status === 'error', `the state is 'error', never stuck on 'checking' (got '${state.status}')`)
|
||||
ok(typeof state.error === 'string' && state.error.length > 0, 'an error message is set for the UI')
|
||||
}
|
||||
|
||||
console.log('a GitHub check that never settles still lands on an error, not on "checking"')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = (url) => (isProbe(url) ? Promise.resolve(okResponse('{}')) : Promise.resolve(okResponse('[]')))
|
||||
// The probe succeeds, so the first (GitHub) attempt is the one that hangs.
|
||||
// electron-updater de-dupes concurrent checks, so the Gitea fallback shares
|
||||
// the abandoned promise — it must still be bounded, not left hanging.
|
||||
let calls = 0
|
||||
ctl.checkForUpdates = () => {
|
||||
calls++
|
||||
return new Promise(() => {})
|
||||
}
|
||||
const state = await call(Ipc.UPDATE_CHECK)
|
||||
ok(calls === 2, `both attempts ran against the same in-flight promise (${calls})`)
|
||||
ok(state.status === 'error', `the state resolved to 'error' (got '${state.status}')`)
|
||||
}
|
||||
|
||||
// ---- 6. the feed choice is per attempt -------------------------------------
|
||||
console.log('the feed choice is not pinned by a transient failure')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
let probeFails = false
|
||||
fetchImpl = (url) =>
|
||||
isProbe(url)
|
||||
? Promise.resolve(probeFails ? errResponse(500) : okResponse('{}'))
|
||||
: Promise.resolve(okResponse('[]'))
|
||||
const attempts = []
|
||||
ctl.checkForUpdates = async () => {
|
||||
attempts.push(lastFeedName())
|
||||
return null
|
||||
}
|
||||
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
ok(attempts.at(-1) === 'github', 'the first check used GitHub')
|
||||
|
||||
probeFails = true
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
ok(attempts.at(-1) === 'gitea', 'with the probe now failing, the next check uses Gitea')
|
||||
|
||||
probeFails = false
|
||||
await call(Ipc.UPDATE_CHECK)
|
||||
ok(attempts.at(-1) === 'github', 'once the probe recovers the next check goes back to GitHub (no pinning)')
|
||||
}
|
||||
|
||||
// ---- 7. event wiring + state -------------------------------------------------
|
||||
console.log('updater events drive the state the renderer reads')
|
||||
{
|
||||
reset()
|
||||
const userData = mkdtempSync(join(tmpdir(), 'ot-updater-'))
|
||||
stub.__setUserData(userData)
|
||||
stub.__setPackaged(true)
|
||||
|
||||
// configureAutoUpdater wires the event listeners and (unless the user turned
|
||||
// startup checks off) schedules a check 5s after launch — unref'd, so it does
|
||||
// not hold the test open.
|
||||
updater.configureAutoUpdater()
|
||||
|
||||
// `on()` publishes the instance the bundle actually subscribed, which is NOT
|
||||
// the one this file required (the bundle inlines its own copy of the stub).
|
||||
const live = ctl.live
|
||||
ok(live !== undefined, 'configureAutoUpdater wired the updater events')
|
||||
ok(live.logger === console, 'the updater logs through console')
|
||||
ok(live.autoDownload === false, 'auto-download is left to the user (the UI offers the button)')
|
||||
ok(live.autoInstallOnAppQuit === true, 'a downloaded update installs on app quit')
|
||||
|
||||
const before = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(before !== undefined && typeof before.currentVersion === 'string', 'UPDATE_STATE_GET returns the current state')
|
||||
ok(before.currentVersion === '0.0.0-stub', 'the state always carries the running version')
|
||||
// `feed` is only reported alongside an availability: it names the feed the
|
||||
// check that FOUND the update used, and there is nothing to name before one.
|
||||
ok(before.feed === undefined, 'no feed is named before an update is found')
|
||||
ok(
|
||||
ctl.feeds.at(-1)?.provider === 'generic',
|
||||
'a packaged launch configures the domestic feed first (the safe default)'
|
||||
)
|
||||
|
||||
live.emit('checking-for-update')
|
||||
{
|
||||
const s = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(s.status === 'checking', "'checking-for-update' sets the checking state")
|
||||
ok(s.error === undefined, 'the previous error is cleared when a new check starts')
|
||||
}
|
||||
|
||||
live.emit('update-available', { version: '9.9.9' })
|
||||
{
|
||||
const s = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(s.status === 'available' && s.version === '9.9.9', `'update-available' carries the version (got ${JSON.stringify(s)})`)
|
||||
ok(s.feed === 'gitea', "the state names the feed the check actually used")
|
||||
ok(s.percent === undefined, 'the percent is cleared for a fresh availability')
|
||||
}
|
||||
|
||||
live.emit('download-progress', { percent: 42.7 })
|
||||
ok((await call(Ipc.UPDATE_STATE_GET)).percent === 43, 'the download percent is rounded for the UI')
|
||||
|
||||
live.emit('update-downloaded', { version: '9.9.9' })
|
||||
{
|
||||
const s = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(s.status === 'downloaded', "'update-downloaded' sets the downloaded state")
|
||||
ok(s.percent === undefined, 'the percent is cleared once downloaded')
|
||||
}
|
||||
|
||||
live.emit('update-not-available')
|
||||
ok((await call(Ipc.UPDATE_STATE_GET)).status === 'latest', "'update-not-available' sets the latest state")
|
||||
|
||||
live.emit('error', new Error('boom'))
|
||||
{
|
||||
const s = await call(Ipc.UPDATE_STATE_GET)
|
||||
ok(s.status === 'error' && s.error === 'boom', "the updater's 'error' event reaches the state")
|
||||
}
|
||||
|
||||
// A non-Error rejection still has to produce a readable message.
|
||||
live.emit('error', 'a string reason')
|
||||
ok((await call(Ipc.UPDATE_STATE_GET)).error === 'a string reason', 'a non-Error error value is stringified')
|
||||
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
stub.__setUserData('')
|
||||
}
|
||||
|
||||
// ---- 8. changelog sources ---------------------------------------------------
|
||||
console.log('changelog: the update channel wins, then the releases APIs')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = (url) => {
|
||||
const u = String(url)
|
||||
if (u.endsWith('release-notes.md')) return Promise.resolve(okResponse('# v9 notes\nline two'))
|
||||
if (u.endsWith('latest.yml')) return Promise.resolve(okResponse("version: 9.9.9\nreleaseDate: '2026-01-02T03:04:05Z'\n"))
|
||||
return Promise.resolve(errResponse(404))
|
||||
}
|
||||
const notes = await call(Ipc.UPDATE_CHANGELOG)
|
||||
ok(Array.isArray(notes) && notes.length === 1, `the channel's release-notes.md is used (${notes.length} entry)`)
|
||||
ok(notes[0].version === 'v9.9.9', `the version comes from latest.yml (got '${notes[0].version}')`)
|
||||
ok(notes[0].date === '2026-01-02', `the date is truncated to the day (got '${notes[0].date}')`)
|
||||
ok(notes[0].body.includes('# v9 notes'), 'the body is the release-notes.md text')
|
||||
}
|
||||
|
||||
console.log('changelog: an empty/stalled channel falls through to the releases API')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
const releases = JSON.stringify([
|
||||
{ tag_name: 'v2.0.0', published_at: '2026-02-03T00:00:00Z', body: 'notes 2' },
|
||||
{ tag_name: 'v1.9.0', published_at: '2026-01-04T00:00:00Z', body: 'notes 1' },
|
||||
{ name: 'named-entry', published_at: '2026-01-05T00:00:00Z', body: 'named' },
|
||||
{ published_at: '2026-01-06T00:00:00Z', body: 'dropped: no version at all' }
|
||||
])
|
||||
fetchImpl = (url) => {
|
||||
const u = String(url)
|
||||
if (u.endsWith('release-notes.md')) return Promise.resolve(okResponse('')) // empty -> fall through
|
||||
if (u.endsWith('latest.yml')) return Promise.resolve(errResponse(404))
|
||||
if (u.includes('git.codingplan.site') && u.includes('releases')) return Promise.resolve(errResponse(404))
|
||||
if (u.includes('api.github.com') && u.includes('releases')) return Promise.resolve(okResponse(releases))
|
||||
return Promise.resolve(errResponse(404))
|
||||
}
|
||||
const notes = await call(Ipc.UPDATE_CHANGELOG)
|
||||
ok(notes.length === 3, `entries without any version are dropped (${notes.length} kept)`)
|
||||
ok(notes[0].version === 'v2.0.0' && notes[0].date === '2026-02-03', 'the newest release is first')
|
||||
ok(notes.some((n) => n.version === 'named-entry'), 'a release with only a name falls back to it')
|
||||
ok(notes.every((n) => n.version !== ''), 'every returned entry has a version')
|
||||
}
|
||||
|
||||
console.log('changelog: every source failing yields an empty list, not a rejection')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
fetchImpl = () => Promise.reject(new Error('offline'))
|
||||
const notes = await call(Ipc.UPDATE_CHANGELOG)
|
||||
ok(Array.isArray(notes) && notes.length === 0, 'the About tab gets [] instead of a thrown error')
|
||||
}
|
||||
|
||||
console.log('changelog: a stalled channel is abandoned at the fetch budget')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
let aborted = 0
|
||||
fetchImpl = (url, init) => {
|
||||
aborted++
|
||||
return neverSettles(url, init)
|
||||
}
|
||||
const started = Date.now()
|
||||
const notes = await call(Ipc.UPDATE_CHANGELOG)
|
||||
const elapsed = Date.now() - started
|
||||
ok(Array.isArray(notes) && notes.length === 0, 'the stalled channel produced no notes')
|
||||
ok(elapsed < 3000, `it gave up at the budget (${elapsed}ms, ${aborted} fetch attempts)`)
|
||||
}
|
||||
|
||||
// ---- 9. install -------------------------------------------------------------
|
||||
console.log('install')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
await call(Ipc.UPDATE_INSTALL)
|
||||
ok(ctl.quitAndInstallCalls.length === 1, 'a packaged build calls quitAndInstall once')
|
||||
ok(ctl.quitAndInstallCalls[0][1] === true, 'and asks for a relaunch (isForceRunAfter)')
|
||||
|
||||
reset()
|
||||
stub.__setPackaged(false)
|
||||
await call(Ipc.UPDATE_INSTALL)
|
||||
ok(ctl.quitAndInstallCalls.length === 0, 'a dev build does not call quitAndInstall')
|
||||
}
|
||||
|
||||
stub.__setPackaged(false)
|
||||
clearInterval(keepAlive)
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[updater-fallback] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\n[updater-fallback] ALL CHECKS PASSED (${passed} assertions)`)
|
||||
Reference in new issue
Block a user