183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
import type { AppLockPasswordVerifier, AppLockSettings } from "../../domain/appLock.ts";
|
||
|
|
import {
|
||
|
|
createOptimisticUnlockedRuntimeState,
|
||
|
|
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
|
||
|
|
getIdleLockDelayMs,
|
||
|
|
normalizeAppLockSystemUnlockStatus,
|
||
|
|
normalizeAppLockSystemUnlockResult,
|
||
|
|
resolveUnlockAttempt,
|
||
|
|
shouldLockAfterIdle,
|
||
|
|
shouldLockOnStartup,
|
||
|
|
} from "./useAppLockState.ts";
|
||
|
|
import { selectPreferredRuntimeAppLockState } from "./useAppLockRuntime.ts";
|
||
|
|
|
||
|
|
const verifier: AppLockPasswordVerifier = {
|
||
|
|
version: 1,
|
||
|
|
algorithm: "PBKDF2-SHA256",
|
||
|
|
iterations: 210000,
|
||
|
|
salt: Buffer.alloc(16, 1).toString("base64"),
|
||
|
|
hash: Buffer.alloc(32, 2).toString("base64"),
|
||
|
|
};
|
||
|
|
|
||
|
|
test("shouldLockOnStartup locks only when enabled with a verifier", () => {
|
||
|
|
const enabled: AppLockSettings = {
|
||
|
|
enabled: true,
|
||
|
|
timeoutMinutes: 15,
|
||
|
|
systemUnlockEnabled: false,
|
||
|
|
systemUnlockAutoPromptEnabled: false,
|
||
|
|
passwordVerifier: verifier,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(shouldLockOnStartup(enabled), true);
|
||
|
|
assert.equal(shouldLockOnStartup({ ...enabled, enabled: false }), false);
|
||
|
|
assert.equal(shouldLockOnStartup({ ...enabled, passwordVerifier: null }), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("shouldLockAfterIdle honors the configured timeout", () => {
|
||
|
|
const settings: AppLockSettings = {
|
||
|
|
enabled: true,
|
||
|
|
timeoutMinutes: 5,
|
||
|
|
systemUnlockEnabled: false,
|
||
|
|
systemUnlockAutoPromptEnabled: false,
|
||
|
|
passwordVerifier: verifier,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000 - 1), false);
|
||
|
|
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000), true);
|
||
|
|
assert.equal(shouldLockAfterIdle({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000 + 60 * 60_000), false);
|
||
|
|
assert.equal(shouldLockAfterIdle({ ...settings, enabled: false }, 1_000, 1_000 + 60 * 60_000), false);
|
||
|
|
assert.equal(shouldLockAfterIdle({ ...settings, passwordVerifier: null }, 1_000, 1_000 + 60 * 60_000), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getIdleLockDelayMs schedules the next check after remaining idle time", () => {
|
||
|
|
const settings: AppLockSettings = {
|
||
|
|
enabled: true,
|
||
|
|
timeoutMinutes: 5,
|
||
|
|
systemUnlockEnabled: false,
|
||
|
|
systemUnlockAutoPromptEnabled: false,
|
||
|
|
passwordVerifier: verifier,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000), 5 * 60_000);
|
||
|
|
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 4 * 60_000), 60_000);
|
||
|
|
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 5 * 60_000), 0);
|
||
|
|
assert.equal(getIdleLockDelayMs({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000), null);
|
||
|
|
assert.equal(getIdleLockDelayMs({ ...settings, enabled: false }, 1_000, 1_000), null);
|
||
|
|
assert.equal(getIdleLockDelayMs({ ...settings, passwordVerifier: null }, 1_000, 1_000), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createOptimisticUnlockedRuntimeState clears stale locked state after successful unlock", () => {
|
||
|
|
const nextState = createOptimisticUnlockedRuntimeState(
|
||
|
|
{
|
||
|
|
initialized: false,
|
||
|
|
locked: true,
|
||
|
|
reason: "startup",
|
||
|
|
version: 7,
|
||
|
|
lastLockedAt: 2_000,
|
||
|
|
lastUnlockedAt: null,
|
||
|
|
lastActivityAt: 1_000,
|
||
|
|
},
|
||
|
|
5_000,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(nextState, {
|
||
|
|
initialized: true,
|
||
|
|
locked: false,
|
||
|
|
reason: null,
|
||
|
|
// Optimistic unlock must not invent a higher version than main has sent.
|
||
|
|
version: 7,
|
||
|
|
lastLockedAt: 2_000,
|
||
|
|
lastUnlockedAt: 5_000,
|
||
|
|
lastActivityAt: 5_000,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createOptimisticUnlockedRuntimeState keeps observed version so concurrent re-lock can win", () => {
|
||
|
|
const optimistic = createOptimisticUnlockedRuntimeState(
|
||
|
|
{
|
||
|
|
initialized: true,
|
||
|
|
locked: true,
|
||
|
|
reason: "startup",
|
||
|
|
version: 7,
|
||
|
|
lastLockedAt: 2_000,
|
||
|
|
lastUnlockedAt: null,
|
||
|
|
lastActivityAt: 1_000,
|
||
|
|
},
|
||
|
|
5_000,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(optimistic.version, 7);
|
||
|
|
assert.equal(optimistic.locked, false);
|
||
|
|
// A concurrent main re-lock at version 8 must outrank the optimistic unlock.
|
||
|
|
const preferred = selectPreferredRuntimeAppLockState(optimistic, {
|
||
|
|
...optimistic,
|
||
|
|
locked: true,
|
||
|
|
reason: "manual",
|
||
|
|
version: 8,
|
||
|
|
lastLockedAt: 6_000,
|
||
|
|
});
|
||
|
|
assert.equal(preferred.locked, true);
|
||
|
|
assert.equal(preferred.version, 8);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("normalizes app lock system unlock bridge status and results", () => {
|
||
|
|
assert.deepEqual(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS, {
|
||
|
|
supported: false,
|
||
|
|
available: false,
|
||
|
|
enabled: false,
|
||
|
|
platform: "unsupported",
|
||
|
|
label: null,
|
||
|
|
reason: null,
|
||
|
|
});
|
||
|
|
assert.deepEqual(
|
||
|
|
normalizeAppLockSystemUnlockStatus({
|
||
|
|
supported: true,
|
||
|
|
available: true,
|
||
|
|
enabled: true,
|
||
|
|
platform: "win32",
|
||
|
|
label: "Windows Hello",
|
||
|
|
reason: "",
|
||
|
|
}),
|
||
|
|
{
|
||
|
|
supported: true,
|
||
|
|
available: true,
|
||
|
|
enabled: true,
|
||
|
|
platform: "win32",
|
||
|
|
label: "Windows Hello",
|
||
|
|
reason: null,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: true }), { ok: true });
|
||
|
|
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "cancelled" }), {
|
||
|
|
ok: false,
|
||
|
|
error: "cancelled",
|
||
|
|
});
|
||
|
|
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "unknown" }), {
|
||
|
|
ok: false,
|
||
|
|
error: "failed",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
test("resolveUnlockAttempt validates empty, incorrect, and correct passwords", async () => {
|
||
|
|
const originalWindow = globalThis.window;
|
||
|
|
globalThis.window = {
|
||
|
|
netcatty: {
|
||
|
|
requestAppLockUnlock: async (password: string) =>
|
||
|
|
password === "secret"
|
||
|
|
? { ok: true as const }
|
||
|
|
: { ok: false as const, error: "incorrect" as const },
|
||
|
|
},
|
||
|
|
} as typeof window;
|
||
|
|
|
||
|
|
try {
|
||
|
|
assert.deepEqual(await resolveUnlockAttempt(""), { ok: false, error: "empty" });
|
||
|
|
assert.deepEqual(await resolveUnlockAttempt("wrong"), { ok: false, error: "incorrect" });
|
||
|
|
assert.deepEqual(await resolveUnlockAttempt("secret"), { ok: true });
|
||
|
|
} finally {
|
||
|
|
globalThis.window = originalWindow;
|
||
|
|
}
|
||
|
|
});
|