- 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
278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Tests for the two events that lock the vault without waiting out the
|
|
// auto-lock countdown, and for the window in which a terminal login's session
|
|
// key is accepted.
|
|
//
|
|
// node tests/lock-triggers.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
const { execFileSync } = require("child_process")
|
|
|
|
const Model = {}
|
|
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
|
.replace(/^\.pragma library\s*$/m, "") + `
|
|
exports.screenLockStateCommand = screenLockStateCommand
|
|
exports.screenIsLocked = screenIsLocked
|
|
exports.screenLockPollMs = screenLockPollMs
|
|
exports.sleepMonitorCommand = sleepMonitorCommand
|
|
exports.sleepSignalToken = sleepSignalToken
|
|
exports.wakeSignalToken = wakeSignalToken
|
|
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
|
exports.handoffWindowOpen = handoffWindowOpen
|
|
exports.handoffWindowMs = handoffWindowMs
|
|
exports.groupedSettings = groupedSettings
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Screen lock
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Only "true" is locked. A shell with the lock plugin disabled answers
|
|
// "Target not found." and exits non-zero; that is "no answer", and a vault
|
|
// that reads it as "locked" would relock itself every few seconds forever.
|
|
const LOCK_ANSWERS = [
|
|
["true", true],
|
|
["true\n", true],
|
|
[" true ", true],
|
|
["false", false],
|
|
["", false],
|
|
["Target not found.", false],
|
|
["TRUE", false],
|
|
["truthy", false],
|
|
[null, false],
|
|
[undefined, false],
|
|
]
|
|
|
|
for (const [raw, want] of LOCK_ANSWERS) {
|
|
check(`screenIsLocked(${JSON.stringify(raw)}) is ${want}`,
|
|
Model.screenIsLocked(raw) === want, String(Model.screenIsLocked(raw)))
|
|
}
|
|
|
|
const lockCmd = Model.screenLockStateCommand()
|
|
check("screen lock state is asked of the shell's own lock plugin",
|
|
lockCmd.join(" ").includes("omarchy-shell lock isLocked"), lockCmd.join(" "))
|
|
|
|
check("screen lock state bounds what it will read back",
|
|
/head -c \d+/.test(lockCmd.join(" ")), lockCmd.join(" "))
|
|
|
|
check("screen lock poll is a sane interval",
|
|
Model.screenLockPollMs() >= 1000 && Model.screenLockPollMs() <= 15000,
|
|
String(Model.screenLockPollMs()))
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Suspend
|
|
// -------------------------------------------------------------------------
|
|
|
|
const sleepScript = Model.sleepMonitorCommand()[2]
|
|
|
|
check("suspend is taken from logind's PrepareForSleep",
|
|
sleepScript.includes("PrepareForSleep") && sleepScript.includes("org.freedesktop.login1"),
|
|
sleepScript)
|
|
|
|
// Without a delay inhibitor logind announces the sleep and suspends without
|
|
// waiting, so the lock would be racing the freeze.
|
|
check("a delay inhibitor is held so the lock lands before the freeze",
|
|
sleepScript.includes("--what=sleep") && sleepScript.includes("--mode=delay"),
|
|
sleepScript)
|
|
|
|
// sed quits on the match, but the monitor would then keep the pipeline open
|
|
// until it next wrote -- which is on the far side of the suspend. Killing it
|
|
// is what lets the inhibitor be released and the loop come round again.
|
|
check("the monitor is killed rather than left to a broken pipe",
|
|
sleepScript.includes("kill \"$g\""), sleepScript)
|
|
|
|
check("the loop never exits, so a failure cannot become a hot restart",
|
|
sleepScript.includes("while :; do") && /sleep 300/.test(sleepScript) && /sleep 5/.test(sleepScript),
|
|
sleepScript)
|
|
|
|
check("sed is unbuffered, so the token is not held back past the suspend",
|
|
/sed -une/.test(sleepScript), sleepScript)
|
|
|
|
// The end-to-end behaviour, against stubs standing in for gdbus and
|
|
// systemd-inhibit: the announcement has to produce exactly one token, the
|
|
// inhibitor has to be released about a second later, and the loop has to come
|
|
// round for the suspend after this one.
|
|
const os = require("os")
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-lock-"))
|
|
try {
|
|
const log = path.join(work, "inhibit.log")
|
|
fs.writeFileSync(log, "")
|
|
|
|
// Real gdbus is a single process, so the stub execs its wait rather than
|
|
// backgrounding it -- otherwise the stub would leak a child that the real
|
|
// thing does not have.
|
|
fs.writeFileSync(path.join(work, "gdbus"), `#!/bin/bash
|
|
echo "Monitoring signals on object /org/freedesktop/login1 owned by org.freedesktop.login1"
|
|
echo "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (false,)"
|
|
echo "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (true,)"
|
|
exec sleep 600
|
|
`)
|
|
fs.writeFileSync(path.join(work, "systemd-inhibit"), `#!/bin/bash
|
|
while [[ "$1" == --* ]]; do shift; done
|
|
echo "ACQUIRED $(date +%s%3N)" >> "${log}"
|
|
"$@"; rc=$?
|
|
echo "RELEASED $(date +%s%3N)" >> "${log}"
|
|
exit $rc
|
|
`)
|
|
for (const f of ["gdbus", "systemd-inhibit"]) fs.chmodSync(path.join(work, f), 0o755)
|
|
|
|
const script = path.join(work, "cmd.sh")
|
|
fs.writeFileSync(script, sleepScript)
|
|
|
|
let out = ""
|
|
try {
|
|
out = execFileSync("bash", ["-c",
|
|
`PATH=${work}:$PATH timeout 4 bash ${script}`], { encoding: "utf8" })
|
|
} catch (e) {
|
|
out = String(e.stdout || "") // timeout always kills it; that is the point
|
|
}
|
|
|
|
const tokens = out.split("\n").map(s => s.trim()).filter(Boolean)
|
|
|
|
check("an announcement produces the sleep token",
|
|
tokens[0] === Model.sleepSignalToken(), JSON.stringify(tokens))
|
|
|
|
check("resuming produces the wake token",
|
|
tokens[1] === Model.wakeSignalToken(), JSON.stringify(tokens))
|
|
|
|
// A `false` announcement is a resume, not a sleep. Two tokens per cycle, so
|
|
// an odd count would mean the resume line matched as well.
|
|
check("only a true announcement counts as a sleep",
|
|
tokens.filter(t => t === Model.sleepSignalToken()).length
|
|
=== tokens.filter(t => t === Model.wakeSignalToken()).length
|
|
|| tokens[tokens.length - 1] === Model.sleepSignalToken(),
|
|
JSON.stringify(tokens))
|
|
|
|
// The bug this shape exists to avoid: the loop coming round only once.
|
|
check("the loop detects more than one suspend per session",
|
|
tokens.filter(t => t === Model.sleepSignalToken()).length >= 2,
|
|
JSON.stringify(tokens))
|
|
|
|
const entries = fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean)
|
|
const acquired = entries.filter(l => l.startsWith("ACQUIRED")).length
|
|
const released = entries.filter(l => l.startsWith("RELEASED")).length
|
|
|
|
check("an inhibitor is taken for every cycle",
|
|
acquired >= 2 && released >= 1 && acquired - released <= 1,
|
|
entries.join(" | "))
|
|
|
|
// Held about a second past the announcement, which is well inside logind's
|
|
// InhibitDelayMaxSec (5s by default) and long enough for the panel to drop
|
|
// the key and for the keyring clear it spawns to finish.
|
|
const firstAcquire = Number(entries[0].split(" ")[1])
|
|
const firstRelease = Number(entries.find(l => l.startsWith("RELEASED")).split(" ")[1])
|
|
const held = firstRelease - firstAcquire
|
|
check("the inhibitor is released promptly, not held across the suspend",
|
|
held >= 900 && held < 4000, `${held}ms`)
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true })
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Session handoff window
|
|
// -------------------------------------------------------------------------
|
|
|
|
const NOW = 1_700_000_000_000
|
|
const WINDOW = Model.handoffWindowMs()
|
|
|
|
check("the window is closed when no terminal login was ever launched",
|
|
Model.handoffWindowOpen(0, NOW) === false, "0")
|
|
|
|
check("the window is open right after launching one",
|
|
Model.handoffWindowOpen(NOW, NOW) === true, "same instant")
|
|
|
|
check("the window is still open partway through a slow login",
|
|
Model.handoffWindowOpen(NOW - WINDOW / 2, NOW) === true, "half the window")
|
|
|
|
check("the window is open at the boundary",
|
|
Model.handoffWindowOpen(NOW - WINDOW, NOW) === true, "exactly the window")
|
|
|
|
check("the window is closed past the boundary",
|
|
Model.handoffWindowOpen(NOW - WINDOW - 1, NOW) === false, "one ms past")
|
|
|
|
// A clock stepped backwards is evidence the clock moved, not that the login
|
|
// was recent, so it must not reopen the window.
|
|
check("a clock stepped backwards closes the window rather than reopening it",
|
|
Model.handoffWindowOpen(NOW + 60_000, NOW) === false, "start in the future")
|
|
|
|
check("a window long enough for a real 2FA login",
|
|
WINDOW >= 5 * 60 * 1000, `${WINDOW}ms`)
|
|
|
|
const expecting = Model.sessionHandoffReadCommand(true)[2]
|
|
const discarding = Model.sessionHandoffReadCommand(false)[2]
|
|
|
|
check("an expected handoff is read out",
|
|
expecting.includes("head -c"), expecting)
|
|
|
|
check("an unexpected handoff is not read out",
|
|
!discarding.includes("head -c"), discarding)
|
|
|
|
// Not reading it is not the same as leaving it there. A live session key in
|
|
// the runtime directory is the worse of the two outcomes.
|
|
check("an unexpected handoff is still removed",
|
|
discarding.includes("rm -f"), discarding)
|
|
|
|
check("an expected handoff is removed once consumed",
|
|
expecting.includes("rm -f"), expecting)
|
|
|
|
// Both forms, run for real against a planted file.
|
|
{
|
|
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-handoff-"))
|
|
try {
|
|
const dir = path.join(runtime, "qs-bitwarden-cli")
|
|
fs.mkdirSync(dir)
|
|
const file = path.join(dir, "session-handoff")
|
|
const KEY = "A".repeat(88)
|
|
|
|
for (const [label, script, wantOut] of [
|
|
["expected", expecting, KEY],
|
|
["unexpected", discarding, ""],
|
|
]) {
|
|
fs.writeFileSync(file, KEY)
|
|
const out = execFileSync("bash", ["-c", script],
|
|
{ encoding: "utf8", env: { ...process.env, XDG_RUNTIME_DIR: runtime } })
|
|
check(`an ${label} handoff returns ${wantOut ? "the key" : "nothing"}`,
|
|
out.trim() === wantOut, JSON.stringify(out))
|
|
check(`an ${label} handoff leaves no file behind`,
|
|
!fs.existsSync(file), "file still present")
|
|
}
|
|
} finally {
|
|
fs.rmSync(runtime, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Both settings reach the settings screen
|
|
// -------------------------------------------------------------------------
|
|
|
|
const keys = Model.groupedSettings().map(e => e.key)
|
|
for (const k of ["lockOnScreenLock", "lockOnSuspend"]) {
|
|
check(`${k} appears in the settings screen`, keys.includes(k), keys.join(", "))
|
|
const entry = Model.groupedSettings().find(e => e.key === k)
|
|
check(`${k} is a toggle in the Security group`,
|
|
entry.type === "bool" && entry.group === "security", JSON.stringify(entry))
|
|
}
|
|
|
|
// The manifest is what the shell reads defaults from, so the two have to agree.
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
|
for (const k of ["lockOnScreenLock", "lockOnSuspend"]) {
|
|
check(`${k} has a manifest default`,
|
|
manifest.barWidget.defaults[k] === true, JSON.stringify(manifest.barWidget.defaults[k]))
|
|
check(`${k} has a manifest schema entry`,
|
|
manifest.barWidget.schema.some(e => e.key === k && e.type === "boolean"), k)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
if (failures.length) {
|
|
console.error(`\n${failures.length} failure(s):\n`)
|
|
for (const f of failures) console.error(` ${f}\n`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`lock-triggers.test.js: ${pass} checks passed`)
|