- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
397 lines
22 KiB
JavaScript
397 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
// What the panel must stop doing when the vault is not open.
|
|
//
|
|
// Three ways it kept going anyway, all of them silent:
|
|
//
|
|
// 1. The auto-lock countdown ran on a Qt Timer, and Qt schedules on
|
|
// CLOCK_MONOTONIC, which Linux stops while the machine is suspended. A
|
|
// fifteen-minute lock armed just before the lid closed still had fifteen
|
|
// minutes left when the lid opened, so a vault left overnight came back
|
|
// open. The deadline is now kept in wall-clock terms as well.
|
|
//
|
|
// 2. The minute count behind that countdown came out of shell.json, and
|
|
// nothing validates shell.json. A non-numeric value reached QML as NaN and
|
|
// landed in an `int` property as 0, which is how "never lock" is spelled; a
|
|
// value past the schema's ceiling overflowed Timer.interval into a negative
|
|
// number, which never fires. Both readings were a vault that never locked.
|
|
//
|
|
// 3. Nothing cancels a `bw` that is already running, so a `bw list items`
|
|
// started a second before the lock finished afterwards and put the whole
|
|
// vault -- passwords and all -- back into a panel that had just dropped it.
|
|
//
|
|
// node tests/lock-state.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
|
|
const Model = {}
|
|
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
|
.replace(/^\.pragma library\s*$/m, "") + `
|
|
exports.intSetting = intSetting
|
|
exports.settingSchemaEntry = settingSchemaEntry
|
|
exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA
|
|
exports.autoLockExpired = autoLockExpired
|
|
exports.autoLockPollMs = autoLockPollMs
|
|
exports.vaultReadIsStale = vaultReadIsStale
|
|
exports.parseItems = parseItems
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
|
|
|
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
|
const bodyOf = (name) => {
|
|
const start = panelSrc.indexOf(`function ${name}(`)
|
|
if (start === -1) return ""
|
|
let depth = 0
|
|
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
|
if (panelSrc[i] === "{") depth++
|
|
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- 1. The schema is the same on both sides of shell.json ------------------
|
|
// The settings screen clamps to SETTINGS_SCHEMA on the way out and the
|
|
// marketplace shows manifest.json's min/max, so the two have to agree or the
|
|
// clamp on the way back in enforces a range nobody was shown.
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
|
const manifestEntries = {}
|
|
for (const e of manifest.barWidget.schema) manifestEntries[e.key] = e
|
|
|
|
for (const entry of Model.SETTINGS_SCHEMA) {
|
|
if (entry.type !== "int") continue
|
|
const m = manifestEntries[entry.key]
|
|
check(`manifest declares ${entry.key}`, !!m, JSON.stringify(Object.keys(manifestEntries)))
|
|
if (!m) continue
|
|
check(`${entry.key} min agrees with the manifest`, m.min === entry.min, `${m.min} vs ${entry.min}`)
|
|
check(`${entry.key} max agrees with the manifest`, m.max === entry.max, `${m.max} vs ${entry.max}`)
|
|
check(`${entry.key} default agrees with the manifest`,
|
|
m.defaultValue === entry.defaultValue, `${m.defaultValue} vs ${entry.defaultValue}`)
|
|
check(`${entry.key} default agrees with the widget defaults block`,
|
|
manifest.barWidget.defaults[entry.key] === entry.defaultValue,
|
|
`${manifest.barWidget.defaults[entry.key]} vs ${entry.defaultValue}`)
|
|
// The ceiling exists so the value can be turned into milliseconds and put in
|
|
// a Timer, whose interval is a signed 32-bit int.
|
|
const scale = entry.key === "autoLockMinutes" ? 60 * 1000 : 1000
|
|
check(`${entry.key} max still fits Timer.interval`,
|
|
entry.max * scale <= 2147483647, `${entry.max * scale}`)
|
|
}
|
|
|
|
// --- 2. Reading a setting back out of shell.json ----------------------------
|
|
// `omarchy bar set` writes whatever it is handed -- a bare word becomes a JSON
|
|
// string, --json stores any number at all -- and the README documents editing
|
|
// the file by hand, so every one of these is reachable.
|
|
for (const [key, raw, want, why] of [
|
|
["autoLockMinutes", 15, 15, "an ordinary value is untouched"],
|
|
["autoLockMinutes", "30", 30, "the string form `omarchy bar set` writes without --json"],
|
|
["autoLockMinutes", 0, 0, "an explicit 0 still means never"],
|
|
["autoLockMinutes", "fifteen", 15, "a word falls back to the default, NOT to 0/never"],
|
|
["autoLockMinutes", undefined, 15, "an unset key falls back to the default"],
|
|
["autoLockMinutes", null, 15, "a null falls back to the default"],
|
|
["autoLockMinutes", "", 15, "an empty string falls back to the default"],
|
|
["autoLockMinutes", true, 15, "a boolean falls back to the default"],
|
|
["autoLockMinutes", 999999, 1440, "a count that would overflow Timer.interval is capped"],
|
|
["autoLockMinutes", 1e30, 1440, "so is one written in exponential notation"],
|
|
// The floor of every integer setting here doubles as its "off" sentinel, so
|
|
// clamping a negative up to it is the silent never-lock this clamp exists to
|
|
// refuse, reached from the other side. Below the range is a bad value, not a
|
|
// request for zero.
|
|
["autoLockMinutes", -5, 15, "a negative count is the default, NOT 0/never"],
|
|
["autoLockMinutes", "-1", 15, "including the string form"],
|
|
["autoLockMinutes", -Infinity, 15, "and the one that arrives as -Infinity"],
|
|
["autoLockMinutes", 15.9, 15, "a fraction truncates rather than reaching the Timer"],
|
|
["clearClipboardSec", 100000, 300, "the clipboard timeout has its own ceiling"],
|
|
["clearClipboardSec", "soon", 30, "and its own default"],
|
|
["clearClipboardSec", -1, 30, "and a negative there is not 'never clear' either"],
|
|
["autoCopyTotpSec", 999, 30, "so does the TOTP delay"],
|
|
["autoCopyTotpSec", "off", 3, "and its default is not 0 either"],
|
|
["autoCopyTotpSec", -3, 3, "and a negative is its default too"],
|
|
]) {
|
|
const got = Model.intSetting(key, raw)
|
|
check(`intSetting(${key}, ${JSON.stringify(raw)}) -> ${want}: ${why}`, got === want, `got ${got}`)
|
|
}
|
|
|
|
check("every clamped value is a finite integer",
|
|
[undefined, null, "", "x", {}, [], NaN, Infinity, -Infinity, 1e400].every(v => {
|
|
const n = Model.intSetting("autoLockMinutes", v)
|
|
return Number.isInteger(n) && n >= 0 && n <= 1440
|
|
}), "one of the junk values escaped the clamp")
|
|
|
|
check("the panel reads its int settings through the clamp",
|
|
/autoLockMinutes:\s*Model\.intSetting\("autoLockMinutes"/.test(panelSrc)
|
|
&& /clearClipboardSec:\s*Model\.intSetting\("clearClipboardSec"/.test(panelSrc)
|
|
&& /autoCopyTotpSec:\s*Model\.intSetting\("autoCopyTotpSec"/.test(panelSrc),
|
|
"expected Model.intSetting() on all three integer settings")
|
|
|
|
// --- 3. The auto-lock deadline survives a suspend ---------------------------
|
|
const t0 = 1700000000000
|
|
check("not expired before the window is up",
|
|
Model.autoLockExpired(t0, 15, t0 + 14 * 60000) === false, "expired early")
|
|
check("expired the moment the window is up",
|
|
Model.autoLockExpired(t0, 15, t0 + 15 * 60000) === true, "did not expire")
|
|
|
|
// The bug itself. The shell is frozen across a suspend, so the monotonic Timer
|
|
// counts only the seconds either side of it; the wall clock counts the night.
|
|
const awakeMsBeforeSuspend = 60 * 1000
|
|
const suspendMs = 12 * 60 * 60 * 1000
|
|
check("a twelve-hour suspend expires a fifteen-minute window",
|
|
Model.autoLockExpired(t0, 15, t0 + awakeMsBeforeSuspend + suspendMs) === true,
|
|
"the vault would have come back unlocked")
|
|
check("the monotonic clock alone would not have noticed",
|
|
awakeMsBeforeSuspend < 15 * 60000, "premise of the test is wrong")
|
|
|
|
check("zero minutes is the user asking for no auto-lock, not an instant one",
|
|
Model.autoLockExpired(t0, 0, t0 + suspendMs) === false, "locked with auto-lock off")
|
|
check("an unarmed window has no deadline to have passed",
|
|
Model.autoLockExpired(0, 15, t0) === false, "locked without ever being armed")
|
|
check("junk cannot make it lock, or stop it locking",
|
|
Model.autoLockExpired(NaN, 15, t0) === false
|
|
&& Model.autoLockExpired(t0, NaN, t0 + suspendMs) === false
|
|
&& Model.autoLockExpired(t0, 15, NaN) === false,
|
|
"a NaN got through")
|
|
|
|
for (const [minutes, want] of [[15, 30000], [60, 30000], [1, 30000], [0, 30000]]) {
|
|
check(`autoLockPollMs(${minutes}) === ${want}`, Model.autoLockPollMs(minutes) === want,
|
|
String(Model.autoLockPollMs(minutes)))
|
|
}
|
|
check("the poll never outlasts the window it is watching",
|
|
[1, 5, 15, 1440].every(m => Model.autoLockPollMs(m) <= m * 60000), "poll longer than the window")
|
|
check("and never busy-loops",
|
|
[0, 1, 15, 1440].every(m => Model.autoLockPollMs(m) >= 1000), "poll under a second")
|
|
|
|
check("the panel arms the window in wall-clock terms",
|
|
/autoLockArmedAt\s*=\s*Date\.now\(\)/.test(bodyOf("resetAutoLockTimer")),
|
|
bodyOf("resetAutoLockTimer"))
|
|
const watchdog = panelSrc.slice(panelSrc.indexOf("id: autoLockWatchdog"),
|
|
panelSrc.indexOf("id: autoLockWatchdog") + 900)
|
|
check("the panel runs a wall-clock watchdog alongside the monotonic timer",
|
|
panelSrc.includes("id: autoLockWatchdog"), "no autoLockWatchdog Timer")
|
|
check("the watchdog repeats, or it is just the monotonic timer again",
|
|
/repeat:\s*true/.test(watchdog), watchdog)
|
|
check("the watchdog only runs on an unlocked vault",
|
|
/running:\s*root\.status === "unlocked" && root\.autoLockMinutes > 0/.test(watchdog), watchdog)
|
|
check("the watchdog asks the wall clock",
|
|
/Model\.autoLockExpired\(root\.autoLockArmedAt, root\.autoLockMinutes, Date\.now\(\)\)/.test(watchdog),
|
|
watchdog)
|
|
check("and locks when it has passed",
|
|
/root\.lockVault\(\)/.test(watchdog), watchdog)
|
|
|
|
// --- 4. A reader that outlived the vault it was reading ---------------------
|
|
check("same generation, session still there: fresh",
|
|
Model.vaultReadIsStale(3, 3, true) === false, "rejected a live result")
|
|
check("the vault changed hands: stale",
|
|
Model.vaultReadIsStale(3, 4, true) === true, "accepted a result from a previous vault")
|
|
check("no session at all: stale whatever the generation says",
|
|
Model.vaultReadIsStale(3, 3, false) === true, "accepted a result into a locked vault")
|
|
check("a reader that never recorded a generation is stale, not fresh",
|
|
Model.vaultReadIsStale(undefined, 0, true) === true, "unstamped read treated as fresh")
|
|
|
|
// Replay of the real sequence, with the panel's own logic standing in for the
|
|
// panel. `bw list items` is in flight; the vault locks; the list lands.
|
|
function fakePanel() {
|
|
return {
|
|
session: "SESSION-A",
|
|
vaultEpoch: 0,
|
|
readEpochs: {},
|
|
items: [],
|
|
itemsLoadedAt: 0,
|
|
beginVaultRead(name) { this.readEpochs[name] = this.vaultEpoch },
|
|
stale(name) { return Model.vaultReadIsStale(this.readEpochs[name], this.vaultEpoch, !!this.session) },
|
|
loadItems() { this.beginVaultRead("items") },
|
|
onListFinished(raw) {
|
|
if (this.stale("items")) return
|
|
this.items = Model.parseItems(raw)
|
|
this.itemsLoadedAt = 1
|
|
},
|
|
lockVault() { this.session = ""; this.vaultEpoch += 1; this.items = []; this.itemsLoadedAt = 0 },
|
|
unlockAs(token) { this.session = token; this.vaultEpoch += 1 }
|
|
}
|
|
}
|
|
|
|
const vaultA = JSON.stringify([
|
|
{ id: "a1", name: "Bank", type: 1, login: { username: "me", password: "hunter2" } }
|
|
])
|
|
const vaultB = JSON.stringify([
|
|
{ id: "b1", name: "Work", type: 1, login: { username: "work", password: "correct-horse" } }
|
|
])
|
|
|
|
let p = fakePanel()
|
|
p.loadItems()
|
|
p.lockVault()
|
|
p.onListFinished(vaultA)
|
|
check("a list that lands after the lock does not refill the vault",
|
|
p.items.length === 0, JSON.stringify(p.items))
|
|
|
|
p = fakePanel()
|
|
p.loadItems()
|
|
p.lockVault()
|
|
p.unlockAs("SESSION-B") // logged out and back in as somebody else
|
|
p.onListFinished(vaultA)
|
|
check("nor after a re-login, where it would have been drawn as the new account's",
|
|
p.items.length === 0, JSON.stringify(p.items))
|
|
p.loadItems()
|
|
p.onListFinished(vaultB)
|
|
check("the new account's own list is accepted",
|
|
p.items.length === 1 && p.items[0].id === "b1", JSON.stringify(p.items))
|
|
check("and the cache is not marked fresh off a discarded answer",
|
|
fakePanel().itemsLoadedAt === 0, "premise")
|
|
|
|
// Every reader the panel has, wired at both ends.
|
|
for (const [name, starter, handler] of [
|
|
["items", "loadItems", "onListFinished"],
|
|
["organizations", "loadOrganizations", "onListOrgsFinished"],
|
|
["folders", "loadFolders", "onListFoldersFinished"],
|
|
["collections", "loadOrgCollections", "onOrgCollectionsLoaded"],
|
|
["detail", "openDetail", "onDetailFinished"],
|
|
["totp", "startTotpFetch", "onTotpFinished"],
|
|
["sends", "loadSends", "onSendsLoaded"],
|
|
]) {
|
|
check(`${starter}() records the vault generation`,
|
|
new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter))
|
|
check(`${handler}() refuses an answer from a vault that has closed`,
|
|
new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\) return`).test(bodyOf(handler)), bodyOf(handler))
|
|
}
|
|
|
|
// Writers and generated values can outlive a lock too. Their server-side
|
|
// effect may already have happened, but no completion may repopulate the
|
|
// locked panel, copy a newly created Send URL, or navigate back to main.
|
|
for (const [name, starter, handler] of [
|
|
["sendCreate", "submitCreateSend", "onSendCreated"],
|
|
["sendDelete", "deleteSend", "onSendDeleted"],
|
|
["generator", "regenerate", "onGenerated"],
|
|
["folderCreate", "submitNewFolder", "onFolderCreated"],
|
|
["sync", "syncVault", "onSyncFinished"],
|
|
["itemSave", "saveItemForm", "onSaveItemFinished"],
|
|
["itemDelete", "deleteCurrentItem", "onDeleteItemFinished"],
|
|
["attachment", "pumpAttachmentQueue", "onAttachmentDownloaded"],
|
|
]) {
|
|
check(`${starter}() stamps the vault operation`,
|
|
new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter))
|
|
check(`${handler}() drops a completion from a vault that has closed`,
|
|
new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\)[\\s\\S]{0,140}return`).test(bodyOf(handler)), bodyOf(handler))
|
|
}
|
|
|
|
const droppedState = bodyOf("dropVaultState")
|
|
check("a queued TOTP request is pinned to the vault generation that queued it",
|
|
/totpQueuedEpoch\s*=\s*vaultEpoch/.test(bodyOf("fetchTotp"))
|
|
&& /queuedEpoch\s*===\s*root\.vaultEpoch/.test(bodyOf("continueTotpQueue")),
|
|
bodyOf("fetchTotp") + "\n" + bodyOf("continueTotpQueue"))
|
|
check("every status request records the current vault generation",
|
|
/beginEpochOperation\("status"\)/.test(bodyOf("runStatusCheck")), bodyOf("runStatusCheck"))
|
|
check("status completion refuses a result from an earlier vault generation",
|
|
/if \(epochOperationIsStale\("status"\)\) return/.test(bodyOf("onStatusFinished")),
|
|
bodyOf("onStatusFinished"))
|
|
check("status requests use the generation-stamped launcher",
|
|
(panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length === 1
|
|
&& /statusProc\.running\s*=\s*true/.test(bodyOf("runStatusCheck")),
|
|
`direct starts: ${(panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length}`)
|
|
check("session handoff reads record and verify their vault generation",
|
|
/beginEpochOperation\("sessionHandoff"\)/.test(bodyOf("refreshStatus"))
|
|
&& /if \(epochOperationIsStale\("sessionHandoff"\)\) return/.test(bodyOf("onSessionHandoff")),
|
|
bodyOf("refreshStatus") + "\n" + bodyOf("onSessionHandoff"))
|
|
check("remembered-session lookups record and verify their vault generation",
|
|
/beginEpochOperation\("keyringLookup"\)/.test(bodyOf("onSessionHandoff"))
|
|
&& /if \(epochOperationIsStale\("keyringLookup"\)\) return/.test(bodyOf("onKeyringLookupFinished")),
|
|
bodyOf("onSessionHandoff") + "\n" + bodyOf("onKeyringLookupFinished"))
|
|
check("logout closes any terminal handoff acceptance window",
|
|
/terminalLoginStartedAt\s*=\s*0/.test(bodyOf("logoutAccount")), bodyOf("logoutAccount"))
|
|
const abandonedAuth = bodyOf("abandonAuthSecrets")
|
|
check("abandoning authentication clears every typed or staged auth secret",
|
|
["masterPassword", "loginPassword", "loginClientId", "loginClientSecret", "login2faCode",
|
|
"pendingUnlockPassword", "authPasswordWriteValue", "pinEntry"].every(prop =>
|
|
new RegExp(`\\b${prop}\\s*=\\s*""`).test(abandonedAuth)),
|
|
abandonedAuth)
|
|
check("handoff, external unlock, and panel hide purge abandoned auth secrets",
|
|
/cancelAuthPrewarm\(\)[\s\S]{0,100}abandonAuthSecrets\(\)/.test(bodyOf("onSessionHandoff"))
|
|
&& /if\s*\(st\.unlocked\)[\s\S]{0,120}abandonAuthSecrets\(\)/.test(bodyOf("onStatusFinished"))
|
|
&& /onOpenedChanged:[\s\S]{0,180}else[\s\S]{0,120}abandonAuthSecrets\(\)/.test(panelSrc),
|
|
bodyOf("onSessionHandoff") + "\n" + bodyOf("onStatusFinished"))
|
|
check("closing the panel invalidates PIN and fingerprint unlock completions",
|
|
/abandonAuthSecrets\(\)/.test(bodyOf("close"))
|
|
&& /pinUnlockSubmitted\s*=\s*false/.test(abandonedAuth)
|
|
&& /cancelFingerprintUnlock\(\)/.test(bodyOf("close")), bodyOf("close"))
|
|
check("closing the panel cancels authentication-method setup writes",
|
|
/abandonPinSetup\(\)/.test(bodyOf("close"))
|
|
&& /abandonFingerprintSetup\(\)/.test(bodyOf("close")), bodyOf("close"))
|
|
check("leaving either authentication setup form cancels its in-flight write",
|
|
/currentScreen\s*!==\s*"pin"[\s\S]*abandonPinSetup\(\)/.test(panelSrc)
|
|
&& /currentScreen\s*!==\s*"fingerprint"[\s\S]*abandonFingerprintSetup\(\)/.test(panelSrc)
|
|
&& /invalidateEpochOperation\("pinStore"\)/.test(bodyOf("abandonPinSetup"))
|
|
&& /invalidateEpochOperation\("masterStore"\)/.test(bodyOf("abandonFingerprintSetup")),
|
|
bodyOf("abandonPinSetup") + "\n" + bodyOf("abandonFingerprintSetup"))
|
|
check("PIN completion requires a still-active submitted unlock",
|
|
/pinUnlockSubmitted\s*&&\s*sshAuthSurfaceActive\s*&&\s*status\s*===\s*"locked"/.test(bodyOf("onPinUnlockResult")),
|
|
bodyOf("onPinUnlockResult"))
|
|
check("fingerprint password retrieval requires a live verified attempt",
|
|
/fingerprintAuthorized/.test(bodyOf("onFingerprintPasswordRetrieved"))
|
|
&& /sshAuthSurfaceActive/.test(bodyOf("onFingerprintPasswordRetrieved"))
|
|
&& /status\s*!==\s*"locked"/.test(bodyOf("onFingerprintPasswordRetrieved")),
|
|
bodyOf("onFingerprintPasswordRetrieved"))
|
|
check("remembered-session stores are generation-stamped and stale stores are cleared",
|
|
/beginEpochOperation\("sessionStore"\)/.test(bodyOf("storeCurrentSession"))
|
|
&& /epochOperationIsStale\("sessionStore"\)/.test(bodyOf("onSessionStored"))
|
|
&& /requestSessionCredentialClear\(\)/.test(bodyOf("onSessionStored")),
|
|
bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored"))
|
|
check("a newer session waits for an old store and its cleanup before being remembered",
|
|
/keyringStoreProc\.running\s*\|\|\s*keyringClearProc\.running/.test(bodyOf("storeCurrentSession"))
|
|
&& /sessionStorePending\s*=\s*true/.test(bodyOf("storeCurrentSession"))
|
|
&& /sessionStorePending\s*=\s*rememberSession\s*&&\s*status\s*===\s*"unlocked"\s*&&\s*!!session/.test(bodyOf("onSessionStored"))
|
|
&& /sessionStorePending[\s\S]{0,100}storeCurrentSession/.test(panelSrc.slice(
|
|
panelSrc.indexOf("id: keyringClearProc"), panelSrc.indexOf("id: listFoldersProc"))),
|
|
bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored"))
|
|
check("PIN stores cannot recreate a credential after the vault generation changes",
|
|
/beginEpochOperation\("pinStore"\)/.test(bodyOf("submitPinSetup"))
|
|
&& /epochOperationIsStale\("pinStore"\)/.test(bodyOf("onPinStored"))
|
|
&& /requestPinCredentialClear\(\)/.test(bodyOf("onPinStored")),
|
|
bodyOf("submitPinSetup") + "\n" + bodyOf("onPinStored"))
|
|
check("master-password stores cannot recreate a credential after lock or logout",
|
|
/beginEpochOperation\("masterStore"\)/.test(bodyOf("submitFingerprintSetup"))
|
|
&& /epochOperationIsStale\("masterStore"\)/.test(bodyOf("onMasterPasswordStored"))
|
|
&& /requestMasterCredentialClear\(\)/.test(bodyOf("onMasterPasswordStored")),
|
|
bodyOf("submitFingerprintSetup") + "\n" + bodyOf("onMasterPasswordStored"))
|
|
check("a learned-association read cannot repopulate account metadata after logout",
|
|
/associationsReadEpoch\s*=\s*associationsEpoch/.test(bodyOf("loadAssociations"))
|
|
&& /associationsReadEpoch\s*!==\s*associationsEpoch/.test(bodyOf("onAssociationsLoaded"))
|
|
&& /associationsEpoch\s*\+=\s*1/.test(bodyOf("forgetStoredCredentials")),
|
|
bodyOf("loadAssociations") + "\n" + bodyOf("onAssociationsLoaded")
|
|
+ "\n" + bodyOf("forgetStoredCredentials"))
|
|
check("credential clears requested during another clear are repeated afterward",
|
|
/sessionClearPending\s*=\s*true/.test(bodyOf("requestSessionCredentialClear"))
|
|
&& /pinClearPending\s*=\s*true/.test(bodyOf("requestPinCredentialClear"))
|
|
&& /masterClearPending\s*=\s*true/.test(bodyOf("requestMasterCredentialClear"))
|
|
&& /allCredentialsClearPending\s*=\s*true/.test(bodyOf("requestAllCredentialClear")),
|
|
"one or more keyring clear paths cannot queue a repeat")
|
|
check("locking uses the centralized local vault purge",
|
|
/dropVaultState\(\)/.test(bodyOf("lockVault")), bodyOf("lockVault"))
|
|
check("unreadable, locked, and logged-out status results purge local vault state",
|
|
(bodyOf("onStatusFinished").match(/dropVaultState\(\)/g) || []).length >= 3,
|
|
bodyOf("onStatusFinished"))
|
|
for (const [prop, empty] of [
|
|
["session", '""'], ["items", "[]"], ["filteredItems", "[]"],
|
|
["organizations", "[]"], ["folders", "[]"], ["detailItem", "null"],
|
|
["formCollections", "[]"], ["formCollectionIds", "[]"],
|
|
["formUsername", '""'], ["formUri", '""'],
|
|
["formNotes", '""'],
|
|
]) {
|
|
check(`the local vault purge clears ${prop}`,
|
|
droppedState.includes(`${prop} = ${empty}`),
|
|
droppedState)
|
|
}
|
|
check("the local vault purge cancels and clears attachment work",
|
|
/cancelAttachmentDownloads\(\)/.test(droppedState)
|
|
&& /attachmentQueue\s*=\s*\[\]/.test(bodyOf("cancelAttachmentDownloads"))
|
|
&& /attachmentBusyId\s*=\s*""/.test(bodyOf("cancelAttachmentDownloads")),
|
|
droppedState + "\n" + bodyOf("cancelAttachmentDownloads"))
|
|
check("the local vault purge clears secret fields and collector buffers",
|
|
/dropVaultSecrets\(\)/.test(droppedState), droppedState)
|
|
|
|
for (const fn of ["onUnlockSuccess", "onSessionHandoff", "onKeyringLookupFinished"]) {
|
|
check(`${fn}() moves the vault generation on`,
|
|
/vaultEpoch \+= 1/.test(bodyOf(fn)), bodyOf(fn))
|
|
}
|
|
|
|
console.log(`${pass} passed, ${failures.length} failed`)
|
|
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|