Files
NetMesh/electron/bridges/appLockRuntimeBridge.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1016 lines
34 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
createAppLockController,
createAppLockRuntimeBridge,
} = require("./appLockRuntimeBridge.cjs");
const {
createAppLockPasswordVerifier,
createAppLockSettingsStore,
} = require("./appLockSettingsStore.cjs");
function withPatchedTimers(run) {
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
let nextTimerId = 1;
const timers = new Map();
global.setTimeout = (fn, delay = 0, ...args) => {
const id = nextTimerId++;
timers.set(id, {
dueAt: Date.now() + Math.max(0, Number(delay) || 0),
fn: () => fn(...args),
});
return id;
};
global.clearTimeout = (id) => {
timers.delete(id);
};
const flushNextTimer = () => {
const nextEntry = timers.entries().next().value;
if (!nextEntry) return false;
const [id, timer] = nextEntry;
timers.delete(id);
timer.fn();
return true;
};
const flushDueTimers = () => {
let flushed = 0;
for (const [id, timer] of [...timers.entries()]) {
if (timer.dueAt > Date.now()) continue;
timers.delete(id);
timer.fn();
flushed += 1;
}
return flushed;
};
const getPendingTimerCount = () => timers.size;
return Promise.resolve()
.then(() => run({ flushNextTimer, flushDueTimers, getPendingTimerCount }))
.finally(() => {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
});
}
function withPatchedDateNow(initialValue, run) {
const originalDateNow = Date.now;
let currentValue = initialValue;
Date.now = () => currentValue;
return Promise.resolve()
.then(() =>
run({
setNow(nextValue) {
currentValue = nextValue;
},
}),
)
.finally(() => {
Date.now = originalDateNow;
});
}
test("runtime bridge can initialize locked at startup", () => {
const bridge = createAppLockRuntimeBridge();
bridge.initialize({
locked: true,
reason: "startup",
lastActivityAt: 1000,
});
const state = bridge.getState();
assert.equal(state.initialized, true);
assert.equal(state.locked, true);
assert.equal(state.reason, "startup");
assert.equal(state.lastActivityAt, 1000);
});
test("runtime bridge records shared activity timestamps", () => {
const bridge = createAppLockRuntimeBridge();
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.recordActivity(2500);
assert.equal(bridge.getState().lastActivityAt, 2500);
});
test("runtime bridge reschedules the shared idle timer after activity", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPatchedDateNow(1000, async ({ setNow }) => {
const bridge = createAppLockRuntimeBridge();
const idleLocks = [];
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.scheduleIdleTimer({
timeoutMinutes: 1,
canLock: () => true,
onIdleLock: (state) => idleLocks.push(state),
});
assert.equal(getPendingTimerCount(), 1);
setNow(30000);
bridge.recordActivity(30000);
assert.equal(getPendingTimerCount(), 1);
setNow(61000);
assert.equal(flushNextTimer(), true);
assert.equal(bridge.getState().locked, false);
assert.equal(idleLocks.length, 0);
assert.equal(getPendingTimerCount(), 1);
setNow(90000);
assert.equal(flushNextTimer(), true);
assert.equal(bridge.getState().locked, true);
assert.equal(bridge.getState().reason, "idle");
assert.equal(idleLocks.length, 1);
assert.equal(getPendingTimerCount(), 0);
});
});
});
test("runtime bridge does not schedule idle timer when timeout is disabled", async () => {
await withPatchedTimers(async ({ getPendingTimerCount }) => {
const bridge = createAppLockRuntimeBridge();
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.scheduleIdleTimer({
timeoutMinutes: 0,
canLock: () => true,
onIdleLock: () => {
throw new Error("idle lock should not run");
},
});
assert.equal(getPendingTimerCount(), 0);
assert.equal(bridge.getState().locked, false);
});
});
test("runtime bridge notifies subscribers on lock state changes", () => {
const bridge = createAppLockRuntimeBridge();
const snapshots = [];
const unsubscribe = bridge.subscribe((state) => {
snapshots.push(state);
});
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.lock("manual");
bridge.unlock();
unsubscribe();
bridge.lock("manual");
assert.deepEqual(
snapshots.map((state) => [state.locked, state.reason]),
[
[false, null],
[true, "manual"],
[false, null],
],
);
});
test("runtime bridge clearIdleTimer cancels pending idle locks", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
const bridge = createAppLockRuntimeBridge();
let locked = false;
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.scheduleIdleTimer({
timeoutMinutes: 1,
canLock: () => true,
onIdleLock: () => {
locked = true;
},
});
assert.equal(getPendingTimerCount(), 1);
bridge.clearIdleTimer();
assert.equal(getPendingTimerCount(), 0);
assert.equal(flushNextTimer(), false);
assert.equal(locked, false);
});
});
test("runtime bridge unreferences the shared idle timer handle when supported", () => {
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
let unrefCalled = false;
global.setTimeout = (fn) => {
void fn;
return {
unref() {
unrefCalled = true;
},
};
};
global.clearTimeout = () => {};
try {
const bridge = createAppLockRuntimeBridge();
bridge.initialize({ locked: false, reason: null, lastActivityAt: 1000 });
bridge.scheduleIdleTimer({
timeoutMinutes: 1,
canLock: () => true,
onIdleLock: () => {},
});
assert.equal(unrefCalled, true);
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
}
});
function createWindowCollector(name) {
const sent = [];
let devToolsOpened = false;
let devToolsCloseCount = 0;
let title = `${name}@host`;
const win = new EventEmitter();
const webContents = new EventEmitter();
Object.assign(webContents, {
id: `${name}-${Math.random()}`,
isDestroyed() {
return false;
},
send(channel, payload) {
sent.push([channel, payload]);
},
isDevToolsOpened() {
return devToolsOpened;
},
closeDevTools() {
devToolsOpened = false;
devToolsCloseCount += 1;
},
});
Object.assign(win, {
name,
sent,
openDevToolsForTest() {
devToolsOpened = true;
webContents.emit("devtools-opened");
},
getDevToolsCloseCount() {
return devToolsCloseCount;
},
getTitle() {
return title;
},
setTitle(nextTitle) {
title = nextTitle;
},
isDestroyed() {
return false;
},
webContents,
});
return win;
}
function createIpcMainHarness() {
const handlers = new Map();
return {
handlers,
handle(channel, handler) {
handlers.set(channel, handler);
},
};
}
async function createControllerHarness(options = {}) {
const settingsStore = createAppLockSettingsStore({
filePath: "/tmp/app-lock-settings.json",
readFile: async () => {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
},
writeFile: async () => {},
});
await settingsStore.load();
const runtimeBridge = createAppLockRuntimeBridge();
runtimeBridge.initialize({
locked: true,
reason: "startup",
lastActivityAt: 1000,
});
const mainWindowA = createWindowCollector("main-a");
const mainWindowB = createWindowCollector("main-b");
// Detached session window: app-content only (registerAsMainWindow:false).
const sessionWindow = createWindowCollector("session");
const settingsWindow = createWindowCollector("settings");
const trayPanelWindow = createWindowCollector("tray");
const popupWindowA = createWindowCollector("popup-a");
const popupWindowB = createWindowCollector("popup-b");
const systemAuthCalls = {
status: 0,
unlock: 0,
};
const systemAuthBridge = {
async getStatus() {
systemAuthCalls.status += 1;
return {
supported: true,
available: true,
platform: "darwin",
label: "Touch ID",
reason: null,
};
},
async requestUnlock() {
systemAuthCalls.unlock += 1;
return { ok: true };
},
};
const unlockFailureDelays = [];
const controller = createAppLockController({
settingsStore,
runtimeBridge,
systemAuthBridge,
getMainWindows: () => [mainWindowA, mainWindowB],
// Main windows are also app-content; include a session-only window that is
// not in getMainWindows (mirrors openSession registerAsMainWindow:false).
getAppContentWindows: () => [mainWindowA, mainWindowB, sessionWindow],
getSettingsWindow: () => settingsWindow,
getTrayPanelWindow: () => trayPanelWindow,
getTerminalPopupWindows: () => [popupWindowA, popupWindowB],
waitForUnlockFailureDelay: options.waitForUnlockFailureDelay ?? (async (delayMs) => {
unlockFailureDelays.push(delayMs);
}),
});
return {
controller,
runtimeBridge,
settingsStore,
systemAuthBridge,
systemAuthCalls,
unlockFailureDelays,
windows: [
mainWindowA,
mainWindowB,
sessionWindow,
settingsWindow,
trayPanelWindow,
popupWindowA,
popupWindowB,
],
};
}
test("system unlock setting confirms with system auth instead of current password when enabling", async () => {
const { controller, systemAuthCalls } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
const saved = await controller.setSystemUnlockEnabled({ enabled: true });
assert.equal(saved.systemUnlockEnabled, true);
assert.equal(systemAuthCalls.unlock, 1);
});
test("system unlock auto prompt setting does not request system auth when already enabled", async () => {
const { controller, systemAuthCalls } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
await controller.setSystemUnlockEnabled({ enabled: true });
assert.equal(systemAuthCalls.unlock, 1);
const enabledAutoPrompt = await controller.setSystemUnlockEnabled({
enabled: true,
autoPromptEnabled: true,
});
assert.equal(enabledAutoPrompt.systemUnlockEnabled, true);
assert.equal(enabledAutoPrompt.systemUnlockAutoPromptEnabled, true);
assert.equal(systemAuthCalls.unlock, 1);
const disabledAutoPrompt = await controller.setSystemUnlockEnabled({
enabled: true,
autoPromptEnabled: false,
});
assert.equal(disabledAutoPrompt.systemUnlockEnabled, true);
assert.equal(disabledAutoPrompt.systemUnlockAutoPromptEnabled, false);
assert.equal(systemAuthCalls.unlock, 1);
});
test("system unlock setting cannot be disabled without password while locked", async () => {
const { controller } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
await controller.setSystemUnlockEnabled({ enabled: true });
controller.setLocked("manual");
assert.deepEqual(
await controller.setSystemUnlockEnabled({ enabled: false }),
{ ok: false, error: "locked" },
);
const saved = await controller.setSystemUnlockEnabled({ enabled: false, currentPassword: "alpha" });
assert.equal(saved.systemUnlockEnabled, false);
});
test("system unlock result is rejected when the setting is disabled during verification", async () => {
const { controller, systemAuthBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
await controller.setSystemUnlockEnabled({ enabled: true });
controller.setLocked("manual");
let resolveSystemUnlock;
systemAuthBridge.requestUnlock = async () => new Promise((resolve) => {
resolveSystemUnlock = resolve;
});
const pendingUnlock = controller.requestSystemUnlock();
for (let attempt = 0; attempt < 5 && !resolveSystemUnlock; attempt += 1) {
await Promise.resolve();
}
assert.equal(typeof resolveSystemUnlock, "function");
const disabled = await controller.setSystemUnlockEnabled({
enabled: false,
currentPassword: "alpha",
});
assert.equal(disabled.systemUnlockEnabled, false);
resolveSystemUnlock({ ok: true });
assert.deepEqual(await pendingUnlock, { ok: false, error: "disabled" });
assert.equal(controller.getRuntimeState().locked, true);
});
test("system unlock succeeds only when enabled and locked", async () => {
const { controller, runtimeBridge, systemAuthCalls } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
assert.deepEqual(await controller.requestSystemUnlock(), { ok: false, error: "disabled" });
await controller.setSystemUnlockEnabled({ enabled: true });
assert.deepEqual(await controller.requestSystemUnlock(), { ok: true });
assert.equal(runtimeBridge.getState().locked, false);
assert.equal(systemAuthCalls.unlock, 2);
assert.deepEqual(await controller.requestSystemUnlock(), { ok: false, error: "not-locked" });
});
test("system unlock cancellation preserves locked runtime state", async () => {
const { controller, runtimeBridge, systemAuthBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
await controller.setSystemUnlockEnabled({ enabled: true, currentPassword: "alpha" });
controller.setLocked("manual");
systemAuthBridge.requestUnlock = async () => ({ ok: false, error: "cancelled" });
assert.deepEqual(await controller.requestSystemUnlock(), { ok: false, error: "cancelled" });
assert.equal(runtimeBridge.getState().locked, true);
});
test("system unlock IPC handlers are registered", async () => {
const { controller } = await createControllerHarness();
const ipcMain = createIpcMainHarness();
controller.registerHandlers(ipcMain);
assert.equal(ipcMain.handlers.has("netcatty:appLock:getSystemUnlockStatus"), true);
assert.equal(ipcMain.handlers.has("netcatty:appLock:setSystemUnlockEnabled"), true);
assert.equal(ipcMain.handlers.has("netcatty:appLock:requestSystemUnlock"), true);
assert.equal(
(await ipcMain.handlers.get("netcatty:appLock:getSystemUnlockStatus")()).label,
"Touch ID",
);
});
test("unlock request verifies against the latest persisted password verifier", async () => {
const { controller } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
assert.deepEqual(await controller.requestUnlock("alpha"), {
ok: true,
});
controller.setLocked("manual");
await controller.requestPasswordChange({
currentPassword: "alpha",
nextPassword: "bravo",
});
controller.setLocked("manual");
assert.deepEqual(await controller.requestUnlock("alpha"), {
ok: false,
error: "incorrect",
});
assert.deepEqual(await controller.requestUnlock("bravo"), {
ok: true,
});
});
test("incorrect password attempts back off and a successful unlock resets the delay", async () => {
const { controller, unlockFailureDelays } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
controller.setLocked("manual");
assert.deepEqual(await controller.requestUnlock("wrong-1"), { ok: false, error: "incorrect" });
assert.deepEqual(await controller.requestUnlock("wrong-2"), { ok: false, error: "incorrect" });
assert.deepEqual(unlockFailureDelays, [250, 500]);
assert.deepEqual(await controller.requestUnlock("alpha"), { ok: true });
controller.setLocked("manual");
assert.deepEqual(await controller.requestUnlock("wrong-3"), { ok: false, error: "incorrect" });
assert.deepEqual(unlockFailureDelays, [250, 500, 250]);
});
test("parallel password attempts share one bounded in-flight verification", async () => {
const delays = [];
let releaseFirstDelay;
const { controller } = await createControllerHarness({
waitForUnlockFailureDelay: async (delayMs) => {
delays.push(delayMs);
if (delays.length === 1) {
await new Promise((resolve) => {
releaseFirstDelay = resolve;
});
}
},
});
await controller.requestPasswordChange({ nextPassword: "alpha" });
controller.setLocked("manual");
const first = controller.requestUnlock("wrong-1");
const second = controller.requestUnlock("wrong-2");
const deadline = Date.now() + 1000;
while (!releaseFirstDelay && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(releaseFirstDelay, "first attempt should reach backoff");
assert.deepEqual(delays, [250], "second attempt must not start another backoff");
releaseFirstDelay();
assert.deepEqual(await Promise.all([first, second]), [
{ ok: false, error: "incorrect" },
{ ok: false, error: "incorrect" },
]);
assert.deepEqual(delays, [250]);
});
test("background lock is skipped when automatic timeout is never", async () => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestUnlock("alpha");
await controller.setTimeoutMinutes(0);
const background = controller.setLocked("background");
assert.equal(background.locked, false);
assert.equal(runtimeBridge.getState().locked, false);
assert.equal(runtimeBridge.getState().reason, null);
const manual = controller.setLocked("manual");
assert.equal(manual.locked, true);
assert.equal(runtimeBridge.getState().reason, "manual");
await controller.requestUnlock("alpha");
const startup = controller.setLocked("startup");
assert.equal(startup.locked, true);
assert.equal(runtimeBridge.getState().reason, "startup");
});
test("background lock still applies when an inactivity timeout is set", async () => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestUnlock("alpha");
await controller.setTimeoutMinutes(5);
const background = controller.setLocked("background");
assert.equal(background.locked, true);
assert.equal(runtimeBridge.getState().reason, "background");
});
test("password unlock result is discarded after a newer lock transition", async () => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
controller.setLocked("manual");
const pending = controller.requestUnlock("alpha");
await Promise.resolve();
controller.setLocked("background");
assert.deepEqual(await pending, { ok: false, error: "incorrect" });
assert.equal(runtimeBridge.getState().locked, true);
assert.equal(runtimeBridge.getState().reason, "background");
});
test("an unlock request started while unlocked cannot unlock a later background lock", async () => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestUnlock("alpha");
assert.equal(runtimeBridge.getState().locked, false);
const pending = controller.requestUnlock("alpha");
controller.setLocked("background");
assert.deepEqual(await pending, { ok: false, error: "incorrect" });
assert.equal(runtimeBridge.getState().locked, true);
assert.equal(runtimeBridge.getState().reason, "background");
});
test("unlocked requests cannot queue ahead of a later legitimate unlock", async () => {
const { controller, runtimeBridge, unlockFailureDelays } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestUnlock("alpha");
const staleCalls = Array.from({ length: 20 }, () => controller.requestUnlock("garbage"));
controller.setLocked("background");
assert.deepEqual(await controller.requestUnlock("alpha"), { ok: true });
assert.equal(runtimeBridge.getState().locked, false);
assert.deepEqual(await Promise.all(staleCalls), Array.from(
{ length: 20 },
() => ({ ok: false, error: "incorrect" }),
));
assert.deepEqual(unlockFailureDelays, []);
});
test("password unlock result is discarded after the verifier changes", async () => {
const { controller, runtimeBridge, settingsStore } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
controller.setLocked("manual");
const replacementVerifier = await createAppLockPasswordVerifier("bravo");
const pending = controller.requestUnlock("alpha");
await Promise.resolve();
await settingsStore.save({
...settingsStore.getSnapshot(),
passwordVerifier: replacementVerifier,
});
assert.deepEqual(await pending, { ok: false, error: "incorrect" });
assert.equal(runtimeBridge.getState().locked, true);
});
test("stale renderer cannot overwrite the latest verifier with a whole-object settings write", async () => {
const { controller, settingsStore } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
const staleSnapshot = settingsStore.getSnapshot();
assert.equal(staleSnapshot.enabled, true);
assert.equal(typeof staleSnapshot.passwordVerifier?.hash, "string");
await controller.requestPasswordChange({
currentPassword: "alpha",
nextPassword: "bravo",
});
const freshSnapshot = settingsStore.getSnapshot();
assert.equal(freshSnapshot.enabled, true);
assert.notEqual(freshSnapshot.passwordVerifier?.hash, staleSnapshot.passwordVerifier?.hash);
assert.deepEqual(
await controller.requestDisable("alpha"),
{ ok: false, error: "incorrect" },
);
assert.deepEqual(
await controller.requestDisable("bravo"),
{
enabled: false,
timeoutMinutes: freshSnapshot.timeoutMinutes,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
},
);
});
test("runtime state broadcast fans out to all main windows, session app-content windows, settings, tray panel, and every popup window", async () => {
const { controller, windows } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
const state = controller.setLocked("manual");
for (const win of windows) {
const runtimeMessages = win.sent.filter(([channel]) => channel === "netcatty:appLock:runtimeStateChanged");
assert.equal(runtimeMessages.length, 1, `${win.name} should receive one runtime broadcast`);
assert.deepEqual(runtimeMessages[0][1], state);
}
const sessionWindow = windows.find((win) => win.name === "session");
assert.ok(sessionWindow, "harness must include a detached session window");
assert.equal(
sessionWindow.sent.filter(([channel]) => channel === "netcatty:appLock:runtimeStateChanged").length,
1,
"detached session windows must receive app-lock runtime broadcasts",
);
});
test("unlocking an enabled app schedules the shared idle timer in controller flow", async () => {
await withPatchedTimers(async ({ getPendingTimerCount }) => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
assert.equal(getPendingTimerCount(), 0);
await controller.requestEnable();
assert.equal(getPendingTimerCount(), 0);
await controller.requestUnlock("alpha");
assert.equal(getPendingTimerCount(), 1);
controller.syncIdleTimer();
await controller.requestDisable("alpha");
runtimeBridge.clearIdleTimer();
});
});
test("unlock and activity keep the shared idle timer armed", async () => {
await withPatchedTimers(async ({ getPendingTimerCount }) => {
await withPatchedDateNow(1000, async ({ setNow }) => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
controller.setLocked("manual");
assert.equal(getPendingTimerCount(), 0);
await controller.requestUnlock("alpha");
assert.equal(getPendingTimerCount(), 1);
setNow(5000);
controller.reportActivity(5000);
assert.equal(getPendingTimerCount(), 1);
await controller.requestDisable("alpha");
runtimeBridge.clearIdleTimer();
});
});
});
test("activity reported from any window postpones the shared idle lock", async () => {
await withPatchedTimers(async ({ flushDueTimers, getPendingTimerCount }) => {
await withPatchedDateNow(1000, async ({ setNow }) => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.setTimeoutMinutes(1);
await controller.requestEnable();
controller.setLocked("manual");
await controller.requestUnlock("alpha");
assert.equal(getPendingTimerCount(), 1);
setNow(30000);
controller.reportActivity(30000);
assert.equal(runtimeBridge.getState().lastActivityAt, 30000);
assert.equal(getPendingTimerCount(), 1);
setNow(61000);
assert.equal(flushDueTimers(), 0);
assert.equal(runtimeBridge.getState().locked, false);
assert.equal(getPendingTimerCount(), 1);
setNow(90000);
assert.equal(flushDueTimers(), 1);
assert.equal(runtimeBridge.getState().locked, true);
assert.equal(runtimeBridge.getState().reason, "idle");
assert.equal(getPendingTimerCount(), 0);
runtimeBridge.clearIdleTimer();
});
});
});
test("idle lock closes DevTools in every app window", async () => {
await withPatchedTimers(async ({ flushDueTimers }) => {
await withPatchedDateNow(1000, async ({ setNow }) => {
const { controller, runtimeBridge, windows } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.setTimeoutMinutes(1);
await controller.requestUnlock("alpha");
windows.forEach((win) => win.openDevToolsForTest());
setNow(61000);
assert.equal(flushDueTimers(), 1);
assert.equal(runtimeBridge.getState().locked, true);
assert.equal(runtimeBridge.getState().reason, "idle");
windows.forEach((win) => {
assert.equal(win.getDevToolsCloseCount(), 1, `${win.name} DevTools should close on idle lock`);
});
});
});
});
function emitSystemContextMenu(win) {
let preventDefaultCount = 0;
win.emit("system-context-menu", {
preventDefault() {
preventDefaultCount += 1;
},
});
return preventDefaultCount;
}
test("lock suppresses the native system context menu on every app window", async () => {
const { controller, windows } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
for (const win of windows) {
assert.equal(
emitSystemContextMenu(win),
1,
`${win.name} should hide the native window menu while locked`,
);
}
await controller.requestUnlock("alpha");
for (const win of windows) {
assert.equal(
emitSystemContextMenu(win),
0,
`${win.name} should keep the native window menu after unlock`,
);
}
controller.setLocked("manual");
for (const win of windows) {
assert.equal(
emitSystemContextMenu(win),
1,
`${win.name} should hide the native window menu after re-lock`,
);
}
});
test("newly opened windows suppress the native system context menu while locked", async () => {
const { controller } = await createControllerHarness();
const newWindow = createWindowCollector("new-session");
controller.protectWindow(newWindow);
assert.equal(emitSystemContextMenu(newWindow), 1);
});
test("startup lock immediately protects existing and newly opened windows", async () => {
const existingWindow = createWindowCollector("existing");
existingWindow.openDevToolsForTest();
const settingsStore = createAppLockSettingsStore({
filePath: "/tmp/app-lock-startup-window.json",
readFile: async () => {
const err = new Error("ENOENT");
err.code = "ENOENT";
throw err;
},
writeFile: async () => {},
});
await settingsStore.load();
const runtimeBridge = createAppLockRuntimeBridge();
runtimeBridge.initialize({ locked: true, reason: "startup", lastActivityAt: 1000 });
const controller = createAppLockController({
settingsStore,
runtimeBridge,
getMainWindows: () => [existingWindow],
});
assert.equal(existingWindow.getTitle(), "Netcatty");
assert.equal(existingWindow.getDevToolsCloseCount(), 1);
const newWindow = createWindowCollector("new-session");
controller.protectWindow(newWindow);
assert.equal(newWindow.getTitle(), "Netcatty");
newWindow.openDevToolsForTest();
assert.equal(newWindow.getDevToolsCloseCount(), 1);
controller.setWindowTitle(newWindow, "new@host");
assert.equal(newWindow.getTitle(), "Netcatty");
runtimeBridge.unlock();
assert.equal(newWindow.getTitle(), "new@host");
});
test("lock redacts window titles and unlock restores them", async () => {
const { controller, windows } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestUnlock("alpha");
const originalTitles = windows.map((win) => win.getTitle());
controller.setLocked("manual");
assert.deepEqual(windows.map((win) => win.getTitle()), windows.map(() => "Netcatty"));
await controller.requestUnlock("alpha");
assert.deepEqual(windows.map((win) => win.getTitle()), originalTitles);
});
test("disabling app lock clears the shared idle timer", async () => {
await withPatchedTimers(async ({ getPendingTimerCount }) => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
await controller.requestUnlock("alpha");
assert.equal(getPendingTimerCount(), 1);
await controller.requestDisable("alpha");
assert.equal(getPendingTimerCount(), 0);
runtimeBridge.clearIdleTimer();
});
});
test("disabling app lock removes the saved password verifier", async () => {
const { controller } = await createControllerHarness();
const saved = await controller.requestDisable("alpha");
assert.equal(saved.enabled, false);
assert.equal(saved.passwordVerifier, null);
});
test("resetting app lock requires the current password before clearing the verifier", async () => {
const { controller, runtimeBridge } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
controller.setLocked("manual");
assert.deepEqual(
await controller.requestReset(),
{ ok: false, error: "empty-current" },
);
assert.deepEqual(
await controller.requestReset("wrong"),
{ ok: false, error: "incorrect" },
);
assert.equal(controller.getSettings().passwordVerifier !== null, true);
assert.equal(runtimeBridge.getState().locked, true);
});
test("resetting app lock clears the verifier, unlocks runtime, and broadcasts settings and runtime", async () => {
const { controller, runtimeBridge, windows } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
await controller.requestEnable();
controller.setLocked("manual");
for (const win of windows) {
win.sent.length = 0;
}
const saved = await controller.requestReset("alpha");
assert.equal(saved.enabled, false);
assert.equal(saved.passwordVerifier, null);
assert.equal(runtimeBridge.getState().locked, false);
assert.equal(runtimeBridge.getState().reason, null);
for (const win of windows) {
const settingsMessages = win.sent.filter(([channel]) => channel === "netcatty:appLock:settingsChanged");
const runtimeMessages = win.sent.filter(([channel]) => channel === "netcatty:appLock:runtimeStateChanged");
assert.equal(settingsMessages.length, 1, `${win.name} should receive one settings broadcast`);
assert.equal(runtimeMessages.length, 1, `${win.name} should receive one runtime broadcast`);
assert.equal(settingsMessages[0][1].passwordVerifier, null);
assert.equal(runtimeMessages[0][1].locked, false);
}
});
test("creating the first app lock password enables app lock", async () => {
const { controller } = await createControllerHarness({
enabled: false,
passwordVerifier: null,
});
const saved = await controller.requestPasswordChange({
nextPassword: "first secret",
});
assert.equal(saved.enabled, true);
assert.equal(typeof saved.passwordVerifier?.hash, "string");
});
test("creating the first app lock password accepts a single space", async () => {
const { controller } = await createControllerHarness({
enabled: false,
passwordVerifier: null,
});
assert.deepEqual(
await controller.requestPasswordChange({ nextPassword: "" }),
{ ok: false, error: "empty-next" },
);
const saved = await controller.requestPasswordChange({ nextPassword: " " });
assert.equal(saved.enabled, true);
assert.equal(typeof saved.passwordVerifier?.hash, "string");
assert.deepEqual(await controller.requestUnlock(" "), { ok: true });
});
test("a queued password change cannot revive a concurrently disabled lock", async () => {
const { controller } = await createControllerHarness();
await controller.requestPasswordChange({ nextPassword: "alpha" });
const disable = controller.requestDisable("alpha");
const passwordChange = controller.requestPasswordChange({
currentPassword: "alpha",
nextPassword: "beta",
});
await Promise.all([passwordChange, disable]);
const settings = controller.getSettings();
assert.equal(settings.enabled, false);
assert.equal(settings.passwordVerifier, null);
});