feat(lock): main-window lock screen; harden env gating, sandbox and ssh teardown
Lock screen (main-window overlay, no second window): - scrypt password verifier in <userData>/lock.json (per-write salt, timingSafeEqual); salt/hash/password never leave the main process - lock now / idle auto-lock / lock at startup, growing failure cooldown, lock flags persisted so a quit-and-relaunch cannot bypass the lock - locked shell and body portals go inert while sessions keep running; menu accelerators (reload, DevTools, zoom) are swallowed while locked - settings gains a Lock tab; all copy in zh-CN/zh-TW/en/ja Security and stability: - packaged builds ignore ELECTRON_RENDERER_URL / OT_UPDATE_URL (devEnv) - renderer preload runs with sandbox: true - unreadable known_hosts store fails closed instead of being overwritten - connect-time secrets gated by the bookmark's auth method (connectPromptFor) - ssh stream teardown is idempotent: PTY_EXIT broadcasts exactly once - sysinfo polling is refcounted for split panes (forceStopPolling on close) - session-log index entries are path-contained; settings store writes atomically with EPERM/EBUSY retry - sync-changelog tolerates CRLF checkouts (was a silent no-op) - retry ssh2 host-key generation (flaky malformed key, ~1/500) Tests: lock-store + lock-controller suites; transport-death PTY_EXIT e2e; GitHub Actions CI (typecheck + 10 offline tests + build)
This commit is contained in:
1 parent
471f8c3e73
commit
35583b2c15
47 files changed
+3058
-105
No files matched your search
@@ -19,7 +19,15 @@ const BUNDLES = [
|
||||
// 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' },
|
||||
// Known-hosts store: TOFU / changed / unreadable fail-closed behavior.
|
||||
{ entry: 'src/main/knownHosts.ts', out: 'tests/.known-hosts.cjs' },
|
||||
{ entry: 'src/main/settingsStore.ts', out: 'tests/.settings-store.cjs' },
|
||||
// Lock-password store: scrypt verifier, round trip, damaged-file handling.
|
||||
{ entry: 'src/main/lockStore.ts', out: 'tests/.lock-store.cjs' },
|
||||
// Lock controller: cooldown ladder, serialized attempts, persisted flags.
|
||||
// Pulls in settingsStore + broadcast, which is why the electron stub needs
|
||||
// powerMonitor as well.
|
||||
{ entry: 'src/main/lockController.ts', out: 'tests/.lock-controller.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'] },
|
||||
|
||||
+108
-5
@@ -10,8 +10,8 @@
|
||||
* --alias:@shared=./src/shared
|
||||
* Run: node tests/commands-store.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { basename, join, resolve, sep } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
@@ -39,7 +39,13 @@ let commandsMod
|
||||
try {
|
||||
commandsMod = require_('./.commands-store.cjs')
|
||||
} catch {
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
|
||||
fail('bundle not found — run: node tests/build-bundles.cjs (or the esbuild line in the header)')
|
||||
}
|
||||
let knownHostsMod
|
||||
try {
|
||||
knownHostsMod = require_('./.known-hosts.cjs')
|
||||
} catch {
|
||||
fail('bundle not found — run: node tests/build-bundles.cjs (builds tests/.known-hosts.cjs)')
|
||||
}
|
||||
|
||||
// ---- 2. Store over the temp userData; capture openDir target -------------------
|
||||
@@ -227,8 +233,105 @@ const expected =
|
||||
'partial-tail'
|
||||
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
|
||||
|
||||
// The store wrote into a temp userData dir; drop it so repeated runs do not
|
||||
// litter %TEMP%.
|
||||
// ---- 7. index.json path containment (hydrateIndex) -----------------------------
|
||||
// index.json is data, not trust: a tampered `file` value must never turn
|
||||
// logWrite into an arbitrary-path append. Only entries that resolve inside
|
||||
// logsDir and point at a regular file may hydrate.
|
||||
const logsDir = join(userData, 'logs')
|
||||
mkdirSync(join(logsDir, 'subdir'), { recursive: true })
|
||||
const escapeFile = join(userData, 'escaped.log')
|
||||
const fwdSlashEntry = `${userData.split(sep).join('/')}/logs/${basename(start.file)}`
|
||||
const driveCaseEntry =
|
||||
process.platform === 'win32'
|
||||
? start.file.replace(/^[A-Z]:/, (m) => m.toLowerCase())
|
||||
: start.file
|
||||
writeFileSync(
|
||||
join(logsDir, 'index.json'),
|
||||
JSON.stringify([
|
||||
{ sessionId: 'escape-abs', file: escapeFile, startedAt: 1 }, // outside logsDir
|
||||
{ sessionId: 'escape-dotdot', file: join(logsDir, '..', 'escaped2.log'), startedAt: 2 },
|
||||
{ sessionId: 'escape-dir', file: join(logsDir, 'subdir'), startedAt: 3 }, // not a regular file
|
||||
{ sessionId: 'ok-legit', file: start.file, startedAt: 4, endedAt: 5 }, // real hydrated log
|
||||
{ sessionId: 'ok-fwdslash', file: fwdSlashEntry, startedAt: 6, endedAt: 7 }, // same file, '/' separators
|
||||
{ sessionId: 'ok-drivecase', file: driveCaseEntry, startedAt: 8, endedAt: 9 } // same file, 'C:' recased
|
||||
]),
|
||||
'utf8'
|
||||
)
|
||||
const store3 = new commandsMod.CommandsStore(userData)
|
||||
let hydrated = store3.listSessionLogs()
|
||||
const ids = hydrated.map((m) => m.sessionId).sort()
|
||||
ok(!ids.includes('escape-abs'), 'absolute path outside logsDir is dropped')
|
||||
ok(!ids.includes('escape-dotdot'), '`..` escape out of logsDir is dropped')
|
||||
ok(!ids.includes('escape-dir'), 'entry pointing at a directory is dropped')
|
||||
ok(ids.includes('ok-legit') && ids.includes('ok-fwdslash'), 'legit entry survives, also spelled with / separators')
|
||||
if (process.platform === 'win32') {
|
||||
ok(ids.includes('ok-drivecase'), 'drive-letter case does not break containment')
|
||||
}
|
||||
// The dropped entries must not come back either: a later index persist only
|
||||
// ever writes the contained subset.
|
||||
store3.logStart('persist-1')
|
||||
const persisted = JSON.parse(readFileSync(join(logsDir, 'index.json'), 'utf8'))
|
||||
ok(
|
||||
!persisted.some((m) => resolve(m.file) === resolve(escapeFile)) &&
|
||||
!persisted.some((m) => resolve(m.file) === resolve(join(userData, 'escaped2.log'))),
|
||||
're-persisted index keeps out-of-logsDir entries out'
|
||||
)
|
||||
ok(
|
||||
!existsSync(escapeFile) && !existsSync(join(userData, 'escaped2.log')),
|
||||
'no log file was created outside logsDir'
|
||||
)
|
||||
|
||||
// ---- 8. KnownHostsStore: TOFU, change detect, unreadable fail-closed -----------
|
||||
const khDir = mkdtempSync(join(tmpdir(), 'm5-kh-'))
|
||||
const khFile = join(khDir, 'ssh_known_hosts.json')
|
||||
const kh = new knownHostsMod.KnownHostsStore(khFile)
|
||||
const keyA = Buffer.from('host-key-a')
|
||||
const keyB = Buffer.from('host-key-b')
|
||||
const fpA = knownHostsMod.fingerprintOf(keyA)
|
||||
const fpB = knownHostsMod.fingerprintOf(keyB)
|
||||
|
||||
ok(kh.check('h1', 22, keyA).status === 'new', 'knownHosts: missing file is TOFU new')
|
||||
kh.accept('h1', 22, keyA, fpA)
|
||||
ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: accepted key matches')
|
||||
const changed = kh.check('h1', 22, keyB)
|
||||
ok(changed.status === 'changed' && changed.stored.fingerprint === fpA, 'knownHosts: other key reports changed + stored fingerprint')
|
||||
|
||||
// Truncated JSON: the file exists but cannot be trusted.
|
||||
writeFileSync(khFile, '{"version":1,"entries":[{"id":"x"', 'utf8')
|
||||
ok(kh.check('h1', 22, keyB).status === 'unreadable', 'knownHosts: corrupt store checks as unreadable, never new')
|
||||
let threw = false
|
||||
try {
|
||||
kh.accept('h1', 22, keyB, fpB)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(threw, 'knownHosts: accept refuses to overwrite an unreadable store')
|
||||
|
||||
// Valid JSON but not the store shape counts as unreadable too.
|
||||
writeFileSync(khFile, '{"nope":true}', 'utf8')
|
||||
ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: shapeless JSON checks as unreadable')
|
||||
|
||||
// A directory at the store path (EISDIR) is unreadable, not "no pins yet".
|
||||
writeFileSync(
|
||||
khFile,
|
||||
JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] })
|
||||
)
|
||||
rmSync(khFile)
|
||||
mkdirSync(khFile)
|
||||
ok(kh.check('h1', 22, keyA).status === 'unreadable', 'knownHosts: a directory at the store path is unreadable, not new')
|
||||
rmSync(khFile, { recursive: true, force: true })
|
||||
|
||||
// Restore the good store: a transient unreadable episode lost nothing.
|
||||
writeFileSync(
|
||||
khFile,
|
||||
JSON.stringify({ version: 1, entries: [{ id: 'x', host: 'h1', port: 22, keyBase64: keyA.toString('base64'), fingerprint: fpA, addedAt: 1 }] })
|
||||
)
|
||||
ok(kh.check('h1', 22, keyA).status === 'match', 'knownHosts: pins survive an unreadable episode')
|
||||
kh.accept('h1', 22, keyB, fpB)
|
||||
ok(kh.check('h1', 22, keyB).status === 'match', 'knownHosts: accept works again once the store is readable')
|
||||
rmSync(khDir, { recursive: true, force: true })
|
||||
|
||||
// All stores wrote into temp dirs; drop them so repeated runs do not litter.
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
console.log('\n[commands] ALL CHECKS PASSED')
|
||||
|
||||
@@ -28,6 +28,9 @@ module.exports = {
|
||||
isRegistered: () => false
|
||||
},
|
||||
webContents: {},
|
||||
powerMonitor: {
|
||||
getSystemIdleTime: () => 0
|
||||
},
|
||||
powerSaveBlocker: {
|
||||
start: () => 1,
|
||||
stop: () => {},
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Lock-controller self-test (lock-controller.mjs).
|
||||
*
|
||||
* Offline and Electron-free: every input the controller has — the two stores,
|
||||
* the settings, the clock, the idle time, the publisher — is injectable, so the
|
||||
* whole state machine runs under plain Node without a window or a real 15s poll.
|
||||
* Guards the contract the lock screen depends on:
|
||||
* - the backoff ladder (1s / 2s / 5s / 10s / 30s, capped) and that an attempt
|
||||
* inside the window is refused as `cooldown`, not answered as `wrong-password`
|
||||
* - a success clears the failure count and the backoff with it
|
||||
* - concurrent attempts are serialized, so firing two at once cannot step
|
||||
* around the backoff (the regression this file exists for)
|
||||
* - `locked` / `failures` / `cooldownUntil` survive a restart, and `start()`
|
||||
* restores them silently (nothing is listening yet)
|
||||
* - a stored lock without a verifier is discarded, never applied
|
||||
* - a cooldown restored from the future is clamped (clock moved backwards)
|
||||
* - idle auto-lock fires exactly once, and never on a broken or disabled input
|
||||
* - clearing the password turns the preferences off and unlocks
|
||||
* - lockNow()/unlock() are no-ops in the directions that would trap the user
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/lock-controller.mjs (must exit 0)
|
||||
*/
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ot-lockctl-'))
|
||||
|
||||
// The controller bundle re-exports only the controller; the stores come from the
|
||||
// lock-store bundle, so the controller is tested against the real scrypt store
|
||||
// rather than a hand-written double.
|
||||
const { LockController } = require('./.lock-controller.cjs')
|
||||
const { LockStore, LockStateStore, defaultLockStatePath } = require('./.lock-store.cjs')
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
const PASSWORD = 'correct horse'
|
||||
|
||||
/** Controllable clock: the whole ladder is driven without ever waiting on it. */
|
||||
let clockMs = 1_700_000_000_000
|
||||
const now = () => clockMs
|
||||
const advance = (ms) => {
|
||||
clockMs += ms
|
||||
}
|
||||
|
||||
/** Idle auto-lock off, so the real 15s poll `start()` installs is a no-op in
|
||||
* every test that is not about idle time (no stray publish mid-assertion). */
|
||||
const idleOff = () => ({ enabled: false, autoLockMinutes: 0, lockAtStartup: false })
|
||||
|
||||
let seq = 0
|
||||
const freshStore = async () => {
|
||||
const store = new LockStore(join(dir, `lock-${seq++}.json`))
|
||||
await store.setPassword(PASSWORD)
|
||||
return store
|
||||
}
|
||||
const freshState = () => new LockStateStore(join(dir, `state-${seq++}.json`))
|
||||
|
||||
/** A controller with every input pinned; returns the publishes it produced. */
|
||||
const makeController = (opts = {}) => {
|
||||
const publishes = []
|
||||
const controller = new LockController({
|
||||
store: opts.store,
|
||||
stateStore: opts.stateStore ?? freshState(),
|
||||
getLockSettings: opts.getLockSettings ?? idleOff,
|
||||
publish: (state) => publishes.push(state),
|
||||
now,
|
||||
idleSeconds: opts.idleSeconds ?? (() => 0),
|
||||
clearLockPreferences: opts.clearLockPreferences ?? (() => {})
|
||||
})
|
||||
return { controller, publishes }
|
||||
}
|
||||
|
||||
/** A real store whose verify() takes a couple of turns of the event loop, so two
|
||||
* attempts fired without awaiting genuinely overlap unless they are serialized. */
|
||||
const slowStore = (store, delayMs = 20) => ({
|
||||
isConfigured: () => store.isConfigured(),
|
||||
setPassword: (password) => store.setPassword(password),
|
||||
clear: () => store.clear(),
|
||||
verify: async (password) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
return store.verify(password)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 1. backoff ladder ------------------------------------------------------
|
||||
console.log('[cooldown ladder]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
const stateStore = freshState()
|
||||
const { controller } = makeController({ store, stateStore })
|
||||
controller.lockNow()
|
||||
ok(controller.isLocked() === true, 'a configured lock can engage')
|
||||
|
||||
const steps = [1000, 2000, 5000, 10000, 30000, 30000]
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const attempt = await controller.unlock({ password: 'wrong' })
|
||||
ok(attempt.ok === false && attempt.error === 'wrong-password', `failure ${i + 1} reports wrong-password`)
|
||||
ok(attempt.state.cooldownMs === steps[i], `failure ${i + 1} backs off for ${steps[i]}ms`)
|
||||
ok(stateStore.load().failures === i + 1, `failure ${i + 1} is on disk`)
|
||||
|
||||
// A second guess inside the window must be answered `cooldown`. Answering
|
||||
// `wrong-password` would mean the password was verified again, so a caller
|
||||
// could keep guessing (and keep escalating the ladder) with no waiting.
|
||||
const blocked = await controller.unlock({ password: 'wrong' })
|
||||
ok(blocked.ok === false && blocked.error === 'cooldown', `failure ${i + 1}: an immediate retry is refused as cooldown`)
|
||||
ok(blocked.state.cooldownMs === steps[i], `failure ${i + 1}: a refused retry does not restart the backoff`)
|
||||
ok(stateStore.load().failures === i + 1, `failure ${i + 1}: a refused retry is not counted as a failure`)
|
||||
|
||||
// Advance by exactly the step: the remaining cooldown reaches 0, so the next
|
||||
// iteration is allowed through the gate again.
|
||||
advance(steps[i])
|
||||
}
|
||||
|
||||
const opened = await controller.unlock({ password: PASSWORD })
|
||||
ok(opened.ok === true && opened.state.locked === false, 'the correct password still opens the screen after the full ladder')
|
||||
}
|
||||
|
||||
// ---- 2. success clears the backoff ------------------------------------------
|
||||
console.log('[success clears]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
const stateStore = freshState()
|
||||
const { controller } = makeController({ store, stateStore })
|
||||
controller.lockNow()
|
||||
const first = await controller.unlock({ password: 'wrong' })
|
||||
advance(first.state.cooldownMs)
|
||||
const second = await controller.unlock({ password: 'wrong' })
|
||||
ok(stateStore.load().failures === 2, 'two failures are recorded')
|
||||
advance(second.state.cooldownMs)
|
||||
|
||||
const opened = await controller.unlock({ password: PASSWORD })
|
||||
ok(opened.ok === true, 'the correct password opens the screen')
|
||||
ok(opened.state.cooldownMs === undefined, 'a success reports no cooldown')
|
||||
ok(
|
||||
stateStore.load().failures === 0 && stateStore.load().cooldownUntil === 0,
|
||||
'a success clears the failure count and the backoff on disk'
|
||||
)
|
||||
|
||||
// Indirect proof that the ladder really restarted: the next failure waits 1s,
|
||||
// which it could not do if the two earlier failures were still counted.
|
||||
controller.lockNow()
|
||||
const after = await controller.unlock({ password: 'wrong' })
|
||||
ok(after.state.cooldownMs === 1000, 'the failure after a success starts the ladder over at 1s')
|
||||
}
|
||||
|
||||
// ---- 3. concurrent attempts are serialized ----------------------------------
|
||||
console.log('[serialized attempts]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
const stateStore = freshState()
|
||||
const { controller } = makeController({ store: slowStore(store), stateStore })
|
||||
controller.lockNow()
|
||||
|
||||
// Fired without awaiting: both calls are already inside the controller before
|
||||
// either verification resolves.
|
||||
const [first, second] = await Promise.all([
|
||||
controller.unlock({ password: 'wrong' }),
|
||||
controller.unlock({ password: 'wrong' })
|
||||
])
|
||||
ok(first.error === 'wrong-password', 'the first of two concurrent attempts is verified and fails')
|
||||
ok(second.error === 'cooldown', 'the second is refused by the backoff the first just raised')
|
||||
ok(stateStore.load().failures === 1, 'two concurrent attempts count as one failure')
|
||||
|
||||
advance(1000)
|
||||
const after = await controller.unlock({ password: PASSWORD })
|
||||
ok(after.ok === true, 'the correct password still works once the cooldown has passed')
|
||||
}
|
||||
|
||||
// ---- 4. persistence round trip ----------------------------------------------
|
||||
console.log('[persistence]')
|
||||
{
|
||||
const lockFile = join(dir, 'lock-persist.json')
|
||||
const stateFile = join(dir, 'state-persist.json')
|
||||
const store = new LockStore(lockFile)
|
||||
await store.setPassword(PASSWORD)
|
||||
|
||||
const first = makeController({ store, stateStore: new LockStateStore(stateFile) })
|
||||
first.controller.lockNow()
|
||||
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'locking writes locked:true to the state file')
|
||||
|
||||
// A relaunch is not a way out of a lock that was already up.
|
||||
const second = makeController({
|
||||
store: new LockStore(lockFile),
|
||||
stateStore: new LockStateStore(stateFile)
|
||||
})
|
||||
second.controller.start()
|
||||
ok(second.controller.isLocked() === true, 'a new instance over the same files starts locked')
|
||||
ok(second.publishes.length === 0, 'start() does not publish: nothing is listening yet')
|
||||
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === true, 'and the restored lock is not written back as unlocked')
|
||||
|
||||
const opened = await second.controller.unlock({ password: PASSWORD })
|
||||
ok(opened.ok === true && opened.state.locked === false, 'the correct password opens the restored lock')
|
||||
ok(JSON.parse(readFileSync(stateFile, 'utf8')).locked === false, 'unlocking writes locked:false to the state file')
|
||||
|
||||
const third = makeController({
|
||||
store: new LockStore(lockFile),
|
||||
stateStore: new LockStateStore(stateFile)
|
||||
})
|
||||
third.controller.start()
|
||||
ok(third.controller.isLocked() === false, 'a fresh instance after an unlock does not lock')
|
||||
}
|
||||
|
||||
// ---- 5. a stored lock with no verifier is discarded -------------------------
|
||||
console.log('[start without a verifier]')
|
||||
{
|
||||
const stateFile = join(dir, 'state-orphan.json')
|
||||
writeFileSync(stateFile, JSON.stringify({ version: 1, locked: true, failures: 2, cooldownUntil: 0 }), 'utf8')
|
||||
const { controller, publishes } = makeController({
|
||||
store: new LockStore(join(dir, 'lock-missing.json')),
|
||||
stateStore: new LockStateStore(stateFile)
|
||||
})
|
||||
controller.start()
|
||||
ok(existsSync(stateFile) === false, 'the stored flags are deleted: no password could ever open that lock again')
|
||||
ok(controller.isLocked() === false, 'and the screen is not locked')
|
||||
ok(publishes.length === 0, 'start() does not publish')
|
||||
}
|
||||
|
||||
// ---- 6. a cooldown restored from the future is clamped ----------------------
|
||||
console.log('[clock skew]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
const stateFile = join(dir, 'state-skew.json')
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ version: 1, locked: false, failures: 3, cooldownUntil: now() + 3_600_000 }),
|
||||
'utf8'
|
||||
)
|
||||
const { controller, publishes } = makeController({ store, stateStore: new LockStateStore(stateFile) })
|
||||
controller.start()
|
||||
ok(controller.getState().cooldownMs === 30000, 'an hour-long restored cooldown is clamped to the longest step')
|
||||
ok(publishes.length === 0, 'start() does not publish')
|
||||
|
||||
// The restored failure count still drives the ladder: the 4th failure waits 10s.
|
||||
advance(30000)
|
||||
controller.lockNow()
|
||||
const attempt = await controller.unlock({ password: 'wrong' })
|
||||
ok(attempt.state.cooldownMs === 10000, 'the restored failure count still picks the matching step')
|
||||
}
|
||||
{
|
||||
const store = await freshStore()
|
||||
const stateFile = join(dir, 'state-keep.json')
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: now() + 5000 }),
|
||||
'utf8'
|
||||
)
|
||||
const { controller } = makeController({ store, stateStore: new LockStateStore(stateFile) })
|
||||
controller.start()
|
||||
ok(controller.getState().cooldownMs === 5000, 'a legitimately stored cooldown is kept exactly as it is')
|
||||
}
|
||||
|
||||
// ---- 7. idle auto-lock ------------------------------------------------------
|
||||
console.log('[idle auto-lock]')
|
||||
{
|
||||
// checkIdle() is only `private` to TypeScript; the modifier is erased at
|
||||
// runtime, so the poll can be driven directly instead of waiting 15 seconds.
|
||||
const store = await freshStore()
|
||||
const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false })
|
||||
|
||||
const idle = makeController({ store, getLockSettings: enabled, idleSeconds: () => 60 })
|
||||
idle.controller.checkIdle()
|
||||
ok(idle.controller.isLocked() === true, 'one minute of idleness locks the screen')
|
||||
ok(
|
||||
idle.publishes.length === 1 && idle.publishes[0].locked === true,
|
||||
'the lock is published once, so the overlay appears without being asked'
|
||||
)
|
||||
idle.controller.checkIdle()
|
||||
ok(idle.publishes.length === 1, 'a poll while already locked publishes nothing')
|
||||
}
|
||||
{
|
||||
const store = await freshStore()
|
||||
const enabled = () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false })
|
||||
|
||||
const justUnder = makeController({ store, getLockSettings: enabled, idleSeconds: () => 59 })
|
||||
justUnder.controller.checkIdle()
|
||||
ok(justUnder.controller.isLocked() === false, '59 seconds is not yet a minute of idleness')
|
||||
ok(justUnder.publishes.length === 0, 'and nothing is published')
|
||||
|
||||
const disabled = makeController({
|
||||
store,
|
||||
getLockSettings: () => ({ ...enabled(), enabled: false }),
|
||||
idleSeconds: () => 3600
|
||||
})
|
||||
disabled.controller.checkIdle()
|
||||
ok(disabled.controller.isLocked() === false, 'the master switch off means idle never locks')
|
||||
|
||||
const never = makeController({
|
||||
store,
|
||||
getLockSettings: () => ({ ...enabled(), autoLockMinutes: 0 }),
|
||||
idleSeconds: () => 3600
|
||||
})
|
||||
never.controller.checkIdle()
|
||||
ok(never.controller.isLocked() === false, 'autoLockMinutes 0 means never')
|
||||
}
|
||||
{
|
||||
const store = await freshStore()
|
||||
const broken = makeController({
|
||||
store,
|
||||
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
|
||||
idleSeconds: () => {
|
||||
throw new Error('powerMonitor is unavailable without a session')
|
||||
}
|
||||
})
|
||||
let threw = false
|
||||
try {
|
||||
broken.controller.checkIdle()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw, 'a failing idle reading does not throw out of the poll')
|
||||
ok(broken.controller.isLocked() === false, 'and unknown idleness reads as not idle rather than locking the app')
|
||||
}
|
||||
{
|
||||
const unconfigured = makeController({
|
||||
store: new LockStore(join(dir, 'lock-noverifier-idle.json')),
|
||||
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
|
||||
idleSeconds: () => 3600
|
||||
})
|
||||
unconfigured.controller.checkIdle()
|
||||
ok(unconfigured.controller.isLocked() === false, 'an unconfigured lock never engages on idle')
|
||||
}
|
||||
|
||||
// ---- 8. clearing the password -----------------------------------------------
|
||||
console.log('[clear password]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
let cleared = 0
|
||||
const { controller } = makeController({ store, clearLockPreferences: () => void cleared++ })
|
||||
controller.lockNow()
|
||||
ok(controller.isLocked() === true, 'the screen is locked before clearing')
|
||||
|
||||
const res = await controller.clearPassword({ currentPassword: PASSWORD })
|
||||
ok(res.ok === true, 'clearing with the correct password succeeds')
|
||||
ok(cleared === 1, 'the lock preferences are turned off exactly once')
|
||||
ok(controller.isLocked() === false, 'clearing also unlocks: an unconfigured lock can never be answered')
|
||||
ok(controller.getState().configured === false, 'the state reports unconfigured')
|
||||
ok(store.isConfigured() === false, 'the verifier file is gone')
|
||||
|
||||
// Wrong current password: removing the lock must not be possible from the
|
||||
// keyboard alone, so nothing is cleared and the screen stays shut.
|
||||
await store.setPassword(PASSWORD)
|
||||
controller.lockNow()
|
||||
ok(controller.isLocked() === true, 'the screen locks again once a password exists')
|
||||
const bad = await controller.clearPassword({ currentPassword: 'wrong' })
|
||||
ok(bad.ok === false && bad.error === 'wrong-password', 'clearing with the wrong password is refused')
|
||||
ok(cleared === 1, 'and the preferences are left alone')
|
||||
ok(store.isConfigured() === true, 'and the password is still set')
|
||||
ok(controller.isLocked() === true, 'and the screen stays locked')
|
||||
}
|
||||
|
||||
// ---- 9. the paths that must not trap the user -------------------------------
|
||||
console.log('[unconfigured paths]')
|
||||
{
|
||||
const { controller, publishes } = makeController({ store: new LockStore(join(dir, 'lock-none.json')) })
|
||||
const state = controller.lockNow()
|
||||
ok(state.locked === false && controller.isLocked() === false, 'lockNow() cannot lock without a verifier')
|
||||
ok(publishes.length === 0, 'and it publishes nothing')
|
||||
}
|
||||
{
|
||||
// Verifier deleted while running: nothing could ever open the screen again, so
|
||||
// it has to open rather than stay shut forever.
|
||||
const store = await freshStore()
|
||||
const { controller } = makeController({ store })
|
||||
controller.lockNow()
|
||||
ok(controller.isLocked() === true, 'a configured lock does engage')
|
||||
store.clear()
|
||||
const res = await controller.unlock({ password: 'anything' })
|
||||
ok(res.ok === true && controller.isLocked() === false, 'unlock() opens a screen whose verifier disappeared')
|
||||
ok(res.state.configured === false, 'and reports it as unconfigured')
|
||||
}
|
||||
|
||||
// ---- 10. applyLocked is idempotent ------------------------------------------
|
||||
console.log('[idempotent lock changes]')
|
||||
{
|
||||
const store = await freshStore()
|
||||
const { controller, publishes } = makeController({
|
||||
store,
|
||||
getLockSettings: () => ({ enabled: true, autoLockMinutes: 1, lockAtStartup: false }),
|
||||
idleSeconds: () => 3600
|
||||
})
|
||||
controller.lockNow()
|
||||
ok(publishes.length === 1, 'the first lock publishes once')
|
||||
controller.lockNow()
|
||||
ok(publishes.length === 1, 'locking an already locked screen publishes nothing')
|
||||
controller.checkIdle()
|
||||
ok(publishes.length === 1, 'the idle watcher does not republish an existing lock')
|
||||
|
||||
await controller.unlock({ password: PASSWORD })
|
||||
ok(publishes.length === 2, 'unlocking publishes once')
|
||||
await controller.unlock({ password: PASSWORD })
|
||||
ok(publishes.length === 2, 'unlocking an unlocked screen publishes nothing')
|
||||
|
||||
store.clear()
|
||||
await controller.clearPassword({})
|
||||
ok(publishes.length === 2, 'clearing an unconfigured lock publishes nothing new')
|
||||
ok(controller.isLocked() === false, 'and leaves the screen unlocked')
|
||||
}
|
||||
|
||||
// ---- 11. path helper --------------------------------------------------------
|
||||
console.log('[paths]')
|
||||
{
|
||||
const statePath = join(dir, 'lock-state.json')
|
||||
ok(
|
||||
resolve(defaultLockStatePath(dir)) === resolve(statePath),
|
||||
'defaultLockStatePath resolves to <userData>/lock-state.json'
|
||||
)
|
||||
}
|
||||
|
||||
// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%.
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
|
||||
console.log(failed === 0 ? '\n[lock-ctl] ALL CHECKS PASSED' : `\n[lock-ctl] ${failed} CHECK(S) FAILED`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Lock-store self-test (lock-store.mjs).
|
||||
*
|
||||
* Offline and Electron-free: the store takes its file path by injection, so it
|
||||
* runs under plain Node. Guards the contract the lock controller relies on:
|
||||
* - a fresh install is "not configured", and looking does not create a file
|
||||
* - setting a password writes a scrypt verifier and never the password
|
||||
* - correct / incorrect verification, case sensitivity, salt regenerated per write
|
||||
* - the verifier survives a restart (a new store over the same file)
|
||||
* - clearing removes the file and returns to "not configured"
|
||||
* - missing / truncated / wrongly-shaped / wrongly-sized files read as
|
||||
* unconfigured instead of throwing (a corrupt lock must not break startup)
|
||||
* - the lock flags (locked / failures / cooldownUntil) round-trip through the
|
||||
* state store, and every unusable shape of that file reads back as "unlocked
|
||||
* with no failures" instead of throwing on the startup path
|
||||
*
|
||||
* Build: node tests/build-bundles.cjs
|
||||
* Run: node tests/lock-store.mjs (must exit 0)
|
||||
*/
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ot-lock-'))
|
||||
const lockFile = join(dir, 'lock.json')
|
||||
|
||||
const lock = require('./.lock-store.cjs')
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
const PASSWORD = 'correct horse'
|
||||
const fresh = () => new lock.LockStore(lockFile)
|
||||
const readRaw = () => readFileSync(lockFile, 'utf8')
|
||||
/** Real base64 for the expected verifier sizes, so a damaged-file case can
|
||||
* break exactly one field. */
|
||||
const SALT16 = Buffer.alloc(16).toString('base64')
|
||||
const HASH64 = Buffer.alloc(64).toString('base64')
|
||||
|
||||
// ---- 1. path helper ---------------------------------------------------------
|
||||
console.log('[paths]')
|
||||
// resolve() so the assertion holds whether the helper joins with '/' or '\'.
|
||||
ok(resolve(lock.defaultLockPath(dir)) === resolve(lockFile), 'defaultLockPath resolves to <userData>/lock.json')
|
||||
|
||||
// ---- 2. unconfigured --------------------------------------------------------
|
||||
console.log('[unconfigured]')
|
||||
{
|
||||
const s = fresh()
|
||||
ok(s.isConfigured() === false, 'a missing file is not configured')
|
||||
ok((await s.verify(PASSWORD)) === false, 'verify() on an unconfigured store is false')
|
||||
ok(existsSync(lockFile) === false, 'verifying does not create the file')
|
||||
}
|
||||
|
||||
// ---- 3. set + verify --------------------------------------------------------
|
||||
console.log('[set + verify]')
|
||||
{
|
||||
const s = fresh()
|
||||
await s.setPassword(PASSWORD)
|
||||
ok(s.isConfigured(), 'after setPassword the store is configured')
|
||||
ok(existsSync(lockFile), 'the verifier file exists')
|
||||
|
||||
const raw = JSON.parse(readRaw())
|
||||
ok(raw.version === 1, 'stored version is 1')
|
||||
ok(typeof raw.salt === 'string' && typeof raw.hash === 'string', 'salt + hash are strings')
|
||||
ok(typeof raw.createdAt === 'number', 'createdAt is a number')
|
||||
ok(!readRaw().includes(PASSWORD), 'the password itself is not on disk')
|
||||
ok(!readRaw().includes('correct'), 'no fragment of the password is on disk')
|
||||
|
||||
ok((await s.verify(PASSWORD)) === true, 'the correct password verifies')
|
||||
ok((await s.verify('correct hors')) === false, 'a near miss does not verify')
|
||||
ok((await s.verify('correct horse ')) === false, 'a trailing space does not verify')
|
||||
ok((await s.verify('')) === false, 'the empty string does not verify')
|
||||
ok((await s.verify('CORRECT HORSE')) === false, 'verification is case sensitive')
|
||||
ok(s.isConfigured(), 'a failed attempt does not unconfigure the store')
|
||||
}
|
||||
{
|
||||
// Replacing regenerates the salt, so the previous hash is worthless even when
|
||||
// the new password is the same one.
|
||||
const s = fresh()
|
||||
await s.setPassword(PASSWORD)
|
||||
const first = JSON.parse(readRaw())
|
||||
await s.setPassword(PASSWORD)
|
||||
const second = JSON.parse(readRaw())
|
||||
ok(first.salt !== second.salt, 'each write generates a fresh salt')
|
||||
ok(first.hash !== second.hash, 'and therefore a different hash')
|
||||
ok((await s.verify(PASSWORD)) === true, 'the replaced verifier still matches the same password')
|
||||
}
|
||||
|
||||
// ---- 4. persistence round trip ----------------------------------------------
|
||||
console.log('[persistence]')
|
||||
{
|
||||
const s = fresh()
|
||||
await s.setPassword(PASSWORD)
|
||||
const reopened = new lock.LockStore(lockFile) // "restart"
|
||||
ok(reopened.isConfigured(), 'a new store over the same file is configured')
|
||||
ok((await reopened.verify(PASSWORD)) === true, 'the password survives a restart')
|
||||
ok((await reopened.verify('nope')) === false, 'a wrong password still fails after a restart')
|
||||
}
|
||||
|
||||
// ---- 5. password length policy ----------------------------------------------
|
||||
console.log('[length policy]')
|
||||
{
|
||||
ok(lock.isValidPassword('abcd') === true, '4 characters is accepted')
|
||||
ok(lock.isValidPassword('a'.repeat(128)) === true, '128 characters is accepted')
|
||||
ok(lock.isValidPassword('abc') === false, '3 characters is refused')
|
||||
ok(lock.isValidPassword('') === false, 'the empty password is refused')
|
||||
ok(lock.isValidPassword('a'.repeat(129)) === false, '129 characters is refused')
|
||||
ok(lock.isValidPassword(undefined) === false, 'a missing password is refused')
|
||||
ok(lock.isValidPassword(1234) === false, 'a non-string password is refused')
|
||||
}
|
||||
{
|
||||
const path = join(dir, 'unset.json')
|
||||
const s = new lock.LockStore(path)
|
||||
let threw = false
|
||||
try {
|
||||
await s.setPassword('abc')
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(threw, 'setPassword refuses a too-short password')
|
||||
threw = false
|
||||
try {
|
||||
await s.setPassword('')
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(threw, 'setPassword refuses an empty password')
|
||||
ok(s.isConfigured() === false, 'a refused password leaves the store unconfigured')
|
||||
ok(existsSync(path) === false, 'nothing was written for a refused password')
|
||||
}
|
||||
|
||||
// ---- 6. clear ---------------------------------------------------------------
|
||||
console.log('[clear]')
|
||||
{
|
||||
const s = fresh()
|
||||
await s.setPassword(PASSWORD)
|
||||
s.clear()
|
||||
ok(s.isConfigured() === false, 'clearing returns the store to unconfigured')
|
||||
ok(existsSync(lockFile) === false, 'clearing removes the file')
|
||||
ok((await s.verify(PASSWORD)) === false, 'a cleared store verifies nothing')
|
||||
}
|
||||
{
|
||||
let threw = false
|
||||
try {
|
||||
fresh().clear()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw, 'clearing an unconfigured store does not throw')
|
||||
}
|
||||
|
||||
// ---- 7. damaged files -------------------------------------------------------
|
||||
console.log('[damaged files]')
|
||||
const damaged = [
|
||||
['truncated JSON', '{"version":1,"salt":"AAAA"'],
|
||||
['an empty file', ''],
|
||||
['JSON null', 'null'],
|
||||
['a JSON array', '[]'],
|
||||
['a bare string', '"nope"'],
|
||||
['an unknown version', JSON.stringify({ version: 2, salt: SALT16, hash: HASH64, createdAt: 1 })],
|
||||
['a missing hash', JSON.stringify({ version: 1, salt: SALT16, createdAt: 1 })],
|
||||
['a non-string salt', JSON.stringify({ version: 1, salt: 42, hash: HASH64, createdAt: 1 })],
|
||||
[
|
||||
'a salt of the wrong size',
|
||||
JSON.stringify({ version: 1, salt: Buffer.alloc(8).toString('base64'), hash: HASH64, createdAt: 1 })
|
||||
],
|
||||
['a hash of the wrong size', JSON.stringify({ version: 1, salt: SALT16, hash: 'AAAA', createdAt: 1 })],
|
||||
['a missing createdAt', JSON.stringify({ version: 1, salt: SALT16, hash: HASH64 })]
|
||||
]
|
||||
for (const [name, content] of damaged) {
|
||||
writeFileSync(lockFile, content, 'utf8')
|
||||
const s = fresh()
|
||||
let threw = false
|
||||
let configured = true
|
||||
let verified = true
|
||||
try {
|
||||
configured = s.isConfigured()
|
||||
verified = await s.verify(PASSWORD)
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(
|
||||
!threw && configured === false && verified === false,
|
||||
`${name} reads as unconfigured without throwing`
|
||||
)
|
||||
}
|
||||
{
|
||||
// A directory at the store path (EISDIR) is unreadable, not "a password is set".
|
||||
const asDir = join(dir, 'as-dir')
|
||||
mkdirSync(asDir, { recursive: true })
|
||||
const s = new lock.LockStore(asDir)
|
||||
let threw = false
|
||||
let configured = true
|
||||
try {
|
||||
configured = s.isConfigured()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw && configured === false, 'a directory at the store path reads as unconfigured')
|
||||
}
|
||||
{
|
||||
// Recovery: a corrupt file is overwritten by the next set, not left blocking.
|
||||
writeFileSync(lockFile, 'not json at all', 'utf8')
|
||||
const s = fresh()
|
||||
await s.setPassword(PASSWORD)
|
||||
ok(s.isConfigured() === true && (await s.verify(PASSWORD)) === true, 'a corrupt file can be replaced')
|
||||
}
|
||||
|
||||
// ---- 8. lock state store ----------------------------------------------------
|
||||
console.log('[lock state store]')
|
||||
const stateFile = join(dir, 'lock-state.json')
|
||||
const stateStore = () => new lock.LockStateStore(stateFile)
|
||||
{
|
||||
ok(
|
||||
resolve(lock.defaultLockStatePath(dir)) === resolve(stateFile),
|
||||
'defaultLockStatePath resolves to <userData>/lock-state.json'
|
||||
)
|
||||
|
||||
const s = stateStore()
|
||||
ok(s.load().locked === false, 'a missing state file reads as unlocked')
|
||||
ok(existsSync(stateFile) === false, 'and reading it does not create the file')
|
||||
}
|
||||
{
|
||||
// The controller writes every flag change through; a lost round trip would hand
|
||||
// back an unlocked app (or a reset backoff) after a restart.
|
||||
const s = stateStore()
|
||||
s.save({ locked: true, failures: 2, cooldownUntil: 1234 })
|
||||
const raw = JSON.parse(readFileSync(stateFile, 'utf8'))
|
||||
ok(raw.version === 1, 'the state file records version 1')
|
||||
const loaded = s.load()
|
||||
ok(
|
||||
loaded.locked === true && loaded.failures === 2 && loaded.cooldownUntil === 1234,
|
||||
'load() returns exactly what save() wrote'
|
||||
)
|
||||
ok(new lock.LockStateStore(stateFile).load().failures === 2, 'the flags survive a restart (a new store over the same file)')
|
||||
}
|
||||
{
|
||||
const s = stateStore()
|
||||
s.save({ locked: true, failures: 1, cooldownUntil: 5000 })
|
||||
s.clear()
|
||||
ok(existsSync(stateFile) === false, 'clear() removes the state file')
|
||||
ok(s.load().locked === false, 'and the flags read back as unlocked')
|
||||
let threw = false
|
||||
try {
|
||||
s.clear()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw, 'clearing an absent state file does not throw')
|
||||
}
|
||||
{
|
||||
// Every one of these must land on the same fallback: the load runs on the
|
||||
// startup path, where throwing would leave the app without a window while it
|
||||
// still holds the single-instance lock.
|
||||
const fallback = (state) =>
|
||||
state.locked === false && state.failures === 0 && state.cooldownUntil === 0
|
||||
const damagedStates = [
|
||||
['an empty file', ''],
|
||||
['truncated JSON', '{"version":1,"locked":true'],
|
||||
['JSON null', 'null'],
|
||||
['a JSON array', '[]'],
|
||||
['a bare string', '"locked"'],
|
||||
['an unknown version', JSON.stringify({ version: 2, locked: true, failures: 3, cooldownUntil: 9 })],
|
||||
['a missing version', JSON.stringify({ locked: true, failures: 3, cooldownUntil: 9 })],
|
||||
['a non-boolean locked', JSON.stringify({ version: 1, locked: 'true', failures: 0, cooldownUntil: 0 })],
|
||||
['locked: 1', JSON.stringify({ version: 1, locked: 1, failures: 0, cooldownUntil: 0 })],
|
||||
['a fractional failure count', JSON.stringify({ version: 1, locked: false, failures: 2.5, cooldownUntil: 0 })],
|
||||
['a negative failure count', JSON.stringify({ version: 1, locked: false, failures: -1, cooldownUntil: 0 })],
|
||||
['a stringified failure count', JSON.stringify({ version: 1, locked: false, failures: '2', cooldownUntil: 0 })],
|
||||
['a missing failure count', JSON.stringify({ version: 1, locked: false, cooldownUntil: 0 })],
|
||||
// JSON has no NaN and no Infinity: both arrive as null, or as text that is
|
||||
// not JSON at all. Either way the cooldown must not become a live deadline.
|
||||
['a nulled cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: null })],
|
||||
['a literal NaN cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":NaN}'],
|
||||
['a literal Infinity cooldown', '{"version":1,"locked":false,"failures":0,"cooldownUntil":Infinity}'],
|
||||
['a stringified cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: '1234' })],
|
||||
['a negative cooldown', JSON.stringify({ version: 1, locked: false, failures: 0, cooldownUntil: -5000 })]
|
||||
]
|
||||
for (const [name, content] of damagedStates) {
|
||||
writeFileSync(stateFile, content, 'utf8')
|
||||
let threw = false
|
||||
let state = null
|
||||
try {
|
||||
state = stateStore().load()
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
ok(!threw && fallback(state), `${name} reads as unlocked with no failures, without throwing`)
|
||||
}
|
||||
}
|
||||
{
|
||||
// A damaged file must not block the next save (the controller writes after
|
||||
// every change, so it has to be able to recover on its own).
|
||||
writeFileSync(stateFile, 'not json at all', 'utf8')
|
||||
const s = stateStore()
|
||||
s.save({ locked: true, failures: 0, cooldownUntil: 0 })
|
||||
ok(s.load().locked === true, 'a damaged state file can be replaced')
|
||||
}
|
||||
|
||||
// The stores wrote into a temp dir; drop it so repeated runs do not litter %TEMP%.
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
|
||||
console.log(failed === 0 ? '\n[lock] ALL CHECKS PASSED' : `\n[lock] ${failed} CHECK(S) FAILED`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
+16
-1
@@ -25,7 +25,22 @@ const fail = (msg) => {
|
||||
}
|
||||
|
||||
// ---- 1. Boot the loopback server -------------------------------------------
|
||||
const serverKey = utils.generateKeyPairSync('ed25519')
|
||||
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
|
||||
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
|
||||
// key"), which is enough to flake a release build's pretest. The Server
|
||||
// constructor parses hostKeys eagerly, so generate until one is accepted.
|
||||
function newHostKey() {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const pair = utils.generateKeyPairSync('ed25519')
|
||||
try {
|
||||
new Server({ hostKeys: [pair.private] }, () => {})
|
||||
return pair
|
||||
} catch (err) {
|
||||
if (attempt >= 9) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
const serverKey = newHostKey()
|
||||
let serverPort = 0
|
||||
let seenWindowChange = { cols: 0, rows: 0 }
|
||||
|
||||
|
||||
@@ -26,11 +26,31 @@ const fail = (msg) => {
|
||||
}
|
||||
|
||||
// ---- 1. Loopback ssh server --------------------------------------------------
|
||||
const serverKey = utils.generateKeyPairSync('ed25519')
|
||||
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
|
||||
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
|
||||
// key"), which is enough to flake a release build's pretest. The Server
|
||||
// constructor parses hostKeys eagerly, so generate until one is accepted.
|
||||
function newHostKey() {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const pair = utils.generateKeyPairSync('ed25519')
|
||||
try {
|
||||
new Server({ hostKeys: [pair.private] }, () => {})
|
||||
return pair
|
||||
} catch (err) {
|
||||
if (attempt >= 9) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
const serverKey = newHostKey()
|
||||
let serverPort = 0
|
||||
let seenWindowChange = { cols: 0, rows: 0 }
|
||||
|
||||
// Latest server-side connection, so a test can hang up on the transport under
|
||||
// an established session (see the PTY_EXIT guard near the end).
|
||||
let serverSideClient = null
|
||||
|
||||
const srv = new Server({ hostKeys: [serverKey.private] }, (client) => {
|
||||
serverSideClient = client
|
||||
client.on('authentication', (ctx) => {
|
||||
if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') {
|
||||
ctx.accept()
|
||||
@@ -108,7 +128,7 @@ sessionLayer.configureSessionRuntime({
|
||||
})
|
||||
|
||||
// ---- 3. Drive the pipeline ----------------------------------------------------
|
||||
const timer = setTimeout(() => fail('e2e timed out'), 15000)
|
||||
const timer = setTimeout(() => fail('e2e timed out'), 25000)
|
||||
const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
|
||||
if (!openResult?.id) fail('openSession did not resolve with an id')
|
||||
console.log(`[e2e] session open: ${openResult.id}`)
|
||||
@@ -149,6 +169,34 @@ console.log('[e2e] kill -> PTY_EXIT ok')
|
||||
if (!events.some((e) => e.channel === 'touched' && e.payload === 'conn-1')) fail('lastConnectedAt touch not recorded')
|
||||
console.log('[e2e] connection touch recorded')
|
||||
|
||||
// ---- 4. Transport death under an established session -------------------------
|
||||
// pty.ts's teardown is now the ONLY PTY_EXIT broadcaster (ssh.ts dropped its own
|
||||
// emit), so this is the guard for both failure modes: a teardown that never
|
||||
// fires leaves the renderer's panel stuck on "connecting" forever, and a broken
|
||||
// idempotence guard would broadcast the exit twice.
|
||||
const open2 = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' })
|
||||
if (!open2?.id) fail('second openSession did not resolve with an id')
|
||||
for (let i = 0; i < 50 && !(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER'); i++) {
|
||||
await wait(100)
|
||||
}
|
||||
if (!(await sessionLayer.getSessionReplay(open2.id)).includes('SESSION-E2E-BANNER')) {
|
||||
fail('second session never became established')
|
||||
}
|
||||
console.log('[e2e] second session established')
|
||||
|
||||
const exitCount = (id) =>
|
||||
events.filter((e) => e.channel === 'pty:exit' && e.payload?.id === id).length
|
||||
if (exitCount(open2.id) !== 0) fail('PTY_EXIT arrived before the transport died')
|
||||
|
||||
// The server hangs up. The client stream must land in the teardown path, which
|
||||
// owns the single broadcast; the waits below give 'close' a moment to arrive.
|
||||
serverSideClient.end()
|
||||
for (let i = 0; i < 50 && exitCount(open2.id) === 0; i++) await wait(100)
|
||||
if (exitCount(open2.id) !== 1) {
|
||||
fail(`transport death must broadcast PTY_EXIT exactly once, got ${exitCount(open2.id)}`)
|
||||
}
|
||||
console.log('[e2e] transport death -> PTY_EXIT exactly once')
|
||||
|
||||
clearTimeout(timer)
|
||||
srv.close()
|
||||
console.log('[e2e] ALL CHECKS PASSED')
|
||||
|
||||
+50
-1
@@ -28,7 +28,22 @@ const fail = (msg) => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// ---- 1. Loopback ssh server with canned /proc exec -----------------------------
|
||||
const serverKey = utils.generateKeyPairSync('ed25519')
|
||||
// ssh2's ed25519 keygen turns out a malformed key roughly once per few
|
||||
// hundred runs — its own parser then rejects it ("Malformed OpenSSH private
|
||||
// key"), which is enough to flake a release build's pretest. The Server
|
||||
// constructor parses hostKeys eagerly, so generate until one is accepted.
|
||||
function newHostKey() {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const pair = utils.generateKeyPairSync('ed25519')
|
||||
try {
|
||||
new Server({ hostKeys: [pair.private] }, () => {})
|
||||
return pair
|
||||
} catch (err) {
|
||||
if (attempt >= 9) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
const serverKey = newHostKey()
|
||||
let serverPort = 0
|
||||
|
||||
// Two successive outputs with rising cpu ticks and rx/tx bytes so rates compute.
|
||||
@@ -198,6 +213,40 @@ const after = samples(openResult.id).length
|
||||
if (after !== before) fail(`stopPolling did not halt: got ${after - before} new samples`)
|
||||
console.log('[sysinfo] stopPolling halts the sample stream')
|
||||
|
||||
// ---- 5. Reference counting: split panels share one sessionId ----------------------
|
||||
// Two panels start on the same session; the poll must survive one stop and
|
||||
// only halt on the last release. Counts grow at ~5 samples/s (200ms interval),
|
||||
// so the waits below must show growth while a reference is held.
|
||||
sessionLayer.startPolling(openResult.id, 200)
|
||||
sessionLayer.startPolling(openResult.id, 200)
|
||||
const refCounted = samples(openResult.id).length
|
||||
await wait(600)
|
||||
if (samples(openResult.id).length <= refCounted) fail('shared poll (refs=2) stopped sampling')
|
||||
sessionLayer.stopPolling(openResult.id) // first panel unmounts
|
||||
const oneRef = samples(openResult.id).length
|
||||
await wait(600)
|
||||
if (samples(openResult.id).length <= oneRef) fail('poll stopped while a sibling panel still held a reference')
|
||||
console.log('[sysinfo] shared poll survives one sibling stop')
|
||||
sessionLayer.stopPolling(openResult.id) // last panel unmounts
|
||||
const zeroRefs = samples(openResult.id).length
|
||||
await wait(600)
|
||||
if (samples(openResult.id).length !== zeroRefs) fail('poll must stop only once the last reference is released')
|
||||
console.log('[sysinfo] last release stops the poll')
|
||||
// Restart after a full release must work on fresh state (no stale refs/timer).
|
||||
sessionLayer.startPolling(openResult.id, 200)
|
||||
await wait(600)
|
||||
if (samples(openResult.id).length <= zeroRefs) fail('poll did not restart cleanly after a full release')
|
||||
sessionLayer.stopPolling(openResult.id)
|
||||
console.log('[sysinfo] restart after full release works')
|
||||
// A killed session must force-stop regardless of outstanding references.
|
||||
sessionLayer.startPolling(openResult.id, 200)
|
||||
sessionLayer.startPolling(openResult.id, 200)
|
||||
sessionLayer.forceStopPolling(openResult.id)
|
||||
const forced = samples(openResult.id).length
|
||||
await wait(600)
|
||||
if (samples(openResult.id).length !== forced) fail('forceStopPolling must halt even with outstanding references')
|
||||
console.log('[sysinfo] forceStopPolling overrides outstanding references')
|
||||
|
||||
clearTimeout(timer)
|
||||
srv.close()
|
||||
console.log('[sysinfo] ALL CHECKS PASSED')
|
||||
|
||||
Reference in new issue
Block a user