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
1016 lines
34 KiB
JavaScript
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);
|
|
});
|