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
1027 lines
32 KiB
TypeScript
1027 lines
32 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import React from "react";
|
|
|
|
import { useAppLockBridge } from "../application/state/useAppLockBridge.ts";
|
|
import { useAppLockState } from "../application/state/useAppLockState.ts";
|
|
import { createAppLockGate } from "./AppLockGate.tsx";
|
|
import { APP_LOCK_AUTO_PROMPT_DELAY_MS } from "./AppLockOverlay.tsx";
|
|
import { createAppLockBridgeHarness } from "./test-support/createAppLockBridgeHarness.ts";
|
|
import {
|
|
createDomRenderer,
|
|
dispatchDomEvent,
|
|
flushEffects,
|
|
installDomEnvironment,
|
|
runWithAct,
|
|
} from "./test-support/renderReactDom.tsx";
|
|
|
|
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
|
|
await runWithAct(async () => {
|
|
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
}
|
|
|
|
test("startup-locked gate reveals children after successful unlock", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("unlocked-content"), null);
|
|
|
|
const form = dom.document.querySelector("form");
|
|
assert.ok(form);
|
|
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
|
assert.ok(input);
|
|
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
dom.window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
assert.ok(setInputValue);
|
|
setInputValue.call(input, "secret");
|
|
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
|
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.deepEqual(bridgeHarness.getUnlockAttempts(), ["secret"]);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, false);
|
|
assert.equal(dom.document.getElementById("unlocked-content")?.textContent, "Unlocked");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("startup-locked gate reveals children after successful system unlock", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
systemUnlockStatus: {
|
|
supported: true,
|
|
available: true,
|
|
enabled: true,
|
|
platform: "darwin",
|
|
label: "Touch ID",
|
|
reason: null,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
systemUnlockEnabled: true,
|
|
systemUnlockAutoPromptEnabled: true,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
await flushEffects();
|
|
await waitForAutoPrompt(dom);
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 1);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, false);
|
|
assert.equal(dom.document.getElementById("unlocked-content")?.textContent, "Unlocked");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("startup-locked gate does not automatically system unlock when auto prompt is disabled", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
systemUnlockStatus: {
|
|
supported: true,
|
|
available: true,
|
|
enabled: true,
|
|
platform: "darwin",
|
|
label: "Touch ID",
|
|
reason: null,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
systemUnlockEnabled: true,
|
|
systemUnlockAutoPromptEnabled: false,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 0);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, true);
|
|
assert.equal(dom.document.getElementById("unlocked-content"), null);
|
|
|
|
const button = [...dom.document.querySelectorAll("button")]
|
|
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
|
assert.ok(button);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("background-locked gate waits for reopen before automatic system unlock", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "background",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
systemUnlockStatus: {
|
|
supported: true,
|
|
available: true,
|
|
enabled: true,
|
|
platform: "darwin",
|
|
label: "Touch ID",
|
|
reason: null,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
systemUnlockEnabled: true,
|
|
systemUnlockAutoPromptEnabled: true,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 0);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, true);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.emitReopen();
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
await waitForAutoPrompt(dom);
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 1);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, false);
|
|
assert.equal(dom.document.getElementById("unlocked-content")?.textContent, "Unlocked");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("background-locked gate retries automatic system unlock on each reopen while still locked", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "background",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
systemUnlockStatus: {
|
|
supported: true,
|
|
available: true,
|
|
enabled: true,
|
|
platform: "darwin",
|
|
label: "Touch ID",
|
|
reason: null,
|
|
},
|
|
systemUnlockResult: { ok: false, error: "cancelled" },
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
systemUnlockEnabled: true,
|
|
systemUnlockAutoPromptEnabled: true,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 0);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.emitReopen();
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
await waitForAutoPrompt(dom);
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 1);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, true);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.emitReopen();
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
await waitForAutoPrompt(dom);
|
|
|
|
assert.equal(bridgeHarness.getSystemUnlockCount(), 2);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, true);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("startup-locked gate reveals children after hidden app lock reset", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "reset-unlocked-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("reset-unlocked-content"), null);
|
|
|
|
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
|
assert.ok(input);
|
|
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
dom.window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
assert.ok(setInputValue);
|
|
setInputValue.call(input, "secret");
|
|
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
|
|
|
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
|
assert.ok(logoButton);
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
|
await flushEffects();
|
|
}
|
|
|
|
const resetButton = [...dom.document.querySelectorAll("button")]
|
|
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
|
assert.ok(resetButton);
|
|
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(bridgeHarness.getResetCount(), 1);
|
|
assert.deepEqual(bridgeHarness.getResetAttempts(), ["secret"]);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, false);
|
|
assert.equal(dom.document.getElementById("reset-unlocked-content")?.textContent, "Unlocked");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("hidden app lock reset stays locked when reset bridge is unavailable", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
const bridgeWithoutReset = { ...bridgeHarness.bridge };
|
|
delete bridgeWithoutReset.requestAppLockReset;
|
|
dom.window.netcatty = bridgeWithoutReset;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "reset-missing-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
|
assert.ok(logoButton);
|
|
for (let index = 0; index < 5; index += 1) {
|
|
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
|
await flushEffects();
|
|
}
|
|
|
|
const resetButton = [...dom.document.querySelectorAll("button")]
|
|
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
|
assert.ok(resetButton);
|
|
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.match(dom.document.body.textContent ?? "", /Could not reset App Lock/i);
|
|
assert.equal(bridgeHarness.getRuntimeState().locked, true);
|
|
assert.equal(dom.document.getElementById("reset-missing-content"), null);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("mounted locked gate uses the latest unlock password without remounting", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
// Start unlocked so children mount once, then re-lock under the overlay
|
|
// (first-paint locks withhold children entirely).
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: false,
|
|
reason: null,
|
|
version: 1,
|
|
lastLockedAt: null,
|
|
lastUnlockedAt: 1_000,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
unlockPassword: "alpha",
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "latest-password-content" }, "Unlocked"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("latest-password-content")?.textContent, "Unlocked");
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: true,
|
|
reason: "manual",
|
|
lastLockedAt: 2_000,
|
|
});
|
|
});
|
|
await flushEffects();
|
|
|
|
bridgeHarness.setUnlockPassword("bravo");
|
|
|
|
const form = dom.document.querySelector("form");
|
|
assert.ok(form);
|
|
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
|
assert.ok(input);
|
|
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
dom.window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
assert.ok(setInputValue);
|
|
|
|
setInputValue.call(input, "alpha");
|
|
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
|
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
// Children stay mounted under the overlay while re-locked.
|
|
assert.equal(dom.document.getElementById("latest-password-content")?.textContent, "Unlocked");
|
|
|
|
setInputValue.call(input, "bravo");
|
|
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
|
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.deepEqual(bridgeHarness.getUnlockAttempts(), ["alpha", "bravo"]);
|
|
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
|
assert.equal(dom.document.getElementById("latest-password-content")?.textContent, "Unlocked");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("relocked gate marks mounted background content inert", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: false,
|
|
reason: null,
|
|
version: 1,
|
|
lastLockedAt: null,
|
|
lastUnlockedAt: 1_000,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "background-app" },
|
|
React.createElement("button", { id: "bg-action", type: "button" }, "Do work"),
|
|
),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
const unlockedBackground = dom.document.querySelector("[data-app-lock-background]");
|
|
assert.ok(unlockedBackground);
|
|
assert.equal(unlockedBackground.getAttribute("data-app-lock-background"), "unlocked");
|
|
assert.equal(unlockedBackground.hasAttribute("inert"), false);
|
|
assert.equal(unlockedBackground.getAttribute("aria-hidden"), null);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: true,
|
|
reason: "idle",
|
|
lastLockedAt: 2_000,
|
|
});
|
|
});
|
|
await flushEffects();
|
|
|
|
const lockedBackground = dom.document.querySelector("[data-app-lock-background]");
|
|
assert.ok(lockedBackground);
|
|
assert.equal(lockedBackground.getAttribute("data-app-lock-background"), "locked");
|
|
assert.equal(lockedBackground.hasAttribute("inert"), true);
|
|
assert.equal(lockedBackground.getAttribute("aria-hidden"), "true");
|
|
assert.ok(
|
|
String(lockedBackground.className || "").includes("pointer-events-none"),
|
|
"locked background should block pointer events",
|
|
);
|
|
assert.equal(dom.document.getElementById("background-app")?.textContent?.includes("Do work"), true);
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("runtime unlock and relock broadcasts update multiple mounted gates together", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
// Mount while unlocked so children exist, then lock/unlock both gates together.
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: false,
|
|
reason: null,
|
|
version: 1,
|
|
lastLockedAt: null,
|
|
lastUnlockedAt: 1_000,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(React.Fragment, null,
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "gate-a" }, "Gate A"),
|
|
}),
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "gate-b" }, "Gate B"),
|
|
}),
|
|
),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("gate-a")?.textContent, "Gate A");
|
|
assert.equal(dom.document.getElementById("gate-b")?.textContent, "Gate B");
|
|
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: true,
|
|
reason: "manual",
|
|
lastLockedAt: 2_000,
|
|
});
|
|
});
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("gate-a")?.textContent, "Gate A");
|
|
assert.equal(dom.document.getElementById("gate-b")?.textContent, "Gate B");
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 2);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: false,
|
|
reason: null,
|
|
lastUnlockedAt: 3_000,
|
|
lastActivityAt: 3_000,
|
|
});
|
|
});
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: true,
|
|
reason: "manual",
|
|
lastLockedAt: 4_000,
|
|
});
|
|
});
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("gate-a")?.textContent, "Gate A");
|
|
assert.equal(dom.document.getElementById("gate-b")?.textContent, "Gate B");
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 2);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("focus recovery resync clears stale overlay state after a missed broadcast", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "manual",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "stale-gate" }, "Unlocked After Resync"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: false,
|
|
reason: null,
|
|
lastUnlockedAt: 2_000,
|
|
lastActivityAt: 2_000,
|
|
}, { notify: false });
|
|
});
|
|
|
|
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
|
assert.equal(dom.document.getElementById("stale-gate")?.textContent, "Unlocked After Resync");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("reopen recovery resync clears stale overlay state after a missed broadcast", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "manual",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "reopen-gate" }, "Unlocked After Reopen"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.setRuntimeState({
|
|
locked: false,
|
|
reason: null,
|
|
lastUnlockedAt: 2_000,
|
|
lastActivityAt: 2_000,
|
|
}, { notify: false });
|
|
bridgeHarness.emitReopen();
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
|
assert.equal(dom.document.getElementById("reopen-gate")?.textContent, "Unlocked After Reopen");
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|
|
|
|
test("reopen resync does not unlock a gate when runtime is still locked", async () => {
|
|
const dom = installDomEnvironment();
|
|
const renderer = await createDomRenderer(dom.document);
|
|
const bridgeHarness = createAppLockBridgeHarness({
|
|
runtimeState: {
|
|
initialized: true,
|
|
locked: true,
|
|
reason: "startup",
|
|
version: 1,
|
|
lastLockedAt: 1_000,
|
|
lastUnlockedAt: null,
|
|
lastActivityAt: 1_000,
|
|
},
|
|
});
|
|
const AppLockGate = createAppLockGate({
|
|
useSettingsState: () => ({
|
|
uiLanguage: "en",
|
|
appLockSettings: {
|
|
enabled: true,
|
|
timeoutMinutes: 15,
|
|
passwordVerifier: {
|
|
version: 1,
|
|
algorithm: "PBKDF2-SHA256",
|
|
iterations: 210000,
|
|
salt: "AAAAAAAAAAAAAAAAAAAAAA==",
|
|
hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
},
|
|
},
|
|
}) as ReturnType<typeof import("../application/state/useSettingsState.ts").useSettingsState>,
|
|
useAppLockState,
|
|
useAppLockBridge,
|
|
});
|
|
|
|
const previousWindowNetcatty = dom.window.netcatty;
|
|
dom.window.netcatty = bridgeHarness.bridge;
|
|
|
|
try {
|
|
await renderer.render(
|
|
React.createElement(AppLockGate, {
|
|
notifyRendererReady: false,
|
|
children: () => React.createElement("div", { id: "startup-locked-child" }, "Should Stay Hidden"),
|
|
}),
|
|
);
|
|
await flushEffects();
|
|
|
|
assert.equal(dom.document.getElementById("startup-locked-child"), null);
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
const fetchCountBeforeReopen = bridgeHarness.getRuntimeFetchCount();
|
|
|
|
await runWithAct(async () => {
|
|
bridgeHarness.emitReopen();
|
|
});
|
|
await flushEffects();
|
|
await flushEffects();
|
|
|
|
assert.equal(bridgeHarness.getRuntimeFetchCount(), fetchCountBeforeReopen + 1);
|
|
assert.equal(dom.document.getElementById("startup-locked-child"), null);
|
|
assert.equal(dom.document.querySelectorAll('[role="dialog"]').length, 1);
|
|
} finally {
|
|
dom.window.netcatty = previousWindowNetcatty;
|
|
await renderer.unmount();
|
|
dom.cleanup();
|
|
}
|
|
});
|