- 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
199 lines
11 KiB
JavaScript
199 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Tests for Bitwarden Send payloads, parsing and command construction.
|
|
// Field names come from a real `bw send --fullObject` response.
|
|
//
|
|
// node tests/sends.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.buildSendPayload = buildSendPayload
|
|
exports.parseSends = parseSends
|
|
exports.sendExpiryLabel = sendExpiryLabel
|
|
exports.sendAccessLabel = sendAccessLabel
|
|
exports.createSendCommand = createSendCommand
|
|
exports.listSendsCommand = listSendsCommand
|
|
exports.deleteSendCommand = deleteSendCommand
|
|
exports.createItemCommand = createItemCommand
|
|
exports.editItemCommand = editItemCommand
|
|
exports.sessionEnvVar = sessionEnvVar
|
|
exports.statusCommand = statusCommand
|
|
exports.listCommand = listCommand
|
|
exports.syncCommand = syncCommand
|
|
exports.getItemCommand = getItemCommand
|
|
exports.getTotpCommand = getTotpCommand
|
|
exports.lockCommand = lockCommand
|
|
exports.deleteItemCommand = deleteItemCommand
|
|
exports.createFolderCommand = createFolderCommand
|
|
exports.listFoldersCommand = listFoldersCommand
|
|
exports.listOrganizationsCommand = listOrganizationsCommand
|
|
exports.terminalLoginCommand = terminalLoginCommand
|
|
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
|
exports.extractSessionToken = extractSessionToken
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
|
|
|
// --- the security property that motivated the env-based commands ------------
|
|
// argv is world-readable via /proc on a default Linux box, so no secret may
|
|
// ever appear in a command line.
|
|
const sendCmd = Model.createSendCommand("SESSIONTOKEN")[2]
|
|
check("send create reads its payload from the environment",
|
|
sendCmd.includes('"$QSBW_SEND"') && /\|\s*base64 -w0\s*\|/.test(sendCmd), sendCmd)
|
|
check("send create carries no payload in argv",
|
|
!sendCmd.includes("password") && !sendCmd.includes("text"), sendCmd)
|
|
|
|
const itemCmd = Model.createItemCommand({ organizationId: null }, "SESSIONTOKEN")[2]
|
|
check("item create reads its payload from the environment",
|
|
itemCmd.includes('"$QSBW_ITEM"'), itemCmd)
|
|
const editCmd = Model.editItemCommand("id-1", "SESSIONTOKEN")[2]
|
|
check("item edit reads its payload from the environment",
|
|
editCmd.includes('"$QSBW_ITEM"') && editCmd.includes("'id-1'"), editCmd)
|
|
|
|
// --- payload ---------------------------------------------------------------
|
|
const p = Model.buildSendPayload("Name", "secret", true, 3, 5, "pw", "note")
|
|
check("payload sets the text and its hidden flag",
|
|
p.text.text === "secret" && p.text.hidden === true, JSON.stringify(p.text))
|
|
check("deletionDate is days in the future, as an ISO string",
|
|
Math.abs(Date.parse(p.deletionDate) - (Date.now() + 3 * 86400000)) < 60000, p.deletionDate)
|
|
check("maxAccessCount of 0 means unlimited, sent as null",
|
|
Model.buildSendPayload("n", "t", false, 7, 0, "", "").maxAccessCount === null, "expected null")
|
|
check("an empty password is sent as null, not an empty string",
|
|
Model.buildSendPayload("n", "t", false, 7, 0, "", "").password === null, "expected null")
|
|
check("a blank name falls back rather than creating an unnamed Send",
|
|
Model.buildSendPayload(" ", "t", false, 7, 0, "", "").name === "Untitled Send",
|
|
Model.buildSendPayload(" ", "t", false, 7, 0, "", "").name)
|
|
for (const [days, lo, hi] of [[0, 1, 31], [999, 1, 31], [-5, 1, 31]]) {
|
|
const got = (Date.parse(Model.buildSendPayload("n", "t", false, days, 0, "", "").deletionDate) - Date.now()) / 86400000
|
|
check(`deleteInDays=${days} clamps into [${lo}, ${hi}]`, got > lo - 1 && got < hi + 1, `got ~${got.toFixed(1)} days`)
|
|
}
|
|
|
|
// --- parsing (shape taken from a real response) -----------------------------
|
|
const listed = Model.parseSends(JSON.stringify([
|
|
{ id: "b", name: "Later", type: 0, accessUrl: "u2", accessCount: 1, maxAccessCount: 3,
|
|
deletionDate: "2026-09-01T00:00:00Z", passwordSet: false, text: { text: "x", hidden: false } },
|
|
{ id: "a", name: "Sooner", type: 1, accessUrl: "u1", accessCount: 0, maxAccessCount: null,
|
|
deletionDate: "2026-08-22T00:00:00Z", passwordSet: true, file: { fileName: "f.pdf" } },
|
|
]))
|
|
check("sends are ordered by how soon they vanish",
|
|
listed.map(s => s.id).join(",") === "a,b", listed.map(s => s.id).join(","))
|
|
check("type 1 is recognised as a file Send",
|
|
listed[0].isFile === true && listed[0].fileName === "f.pdf", JSON.stringify(listed[0]))
|
|
check("passwordSet is carried through as a boolean, and no password is exposed",
|
|
listed[0].passwordSet === true && !("password" in listed[0]), JSON.stringify(listed[0]))
|
|
check("malformed JSON yields an empty list",
|
|
Model.parseSends("{{").length === 0 && Model.parseSends("").length === 0, "expected []")
|
|
|
|
// --- labels -----------------------------------------------------------------
|
|
const at = t => Date.parse(t)
|
|
const mk = d => ({ deletionDate: d })
|
|
check("a past deletion date reads as expired",
|
|
Model.sendExpiryLabel(mk("2026-08-20T00:00:00Z"), at("2026-08-21T00:00:00Z")) === "expired", "expected expired")
|
|
check("hours are used under a day",
|
|
Model.sendExpiryLabel(mk("2026-08-21T05:00:00Z"), at("2026-08-21T00:00:00Z")) === "in 5 hours", "expected 'in 5 hours'")
|
|
check("singular day is not pluralised",
|
|
Model.sendExpiryLabel(mk("2026-08-22T00:00:00Z"), at("2026-08-21T00:00:00Z")) === "in 1 day", "expected 'in 1 day'")
|
|
check("a missing deletion date yields no label",
|
|
Model.sendExpiryLabel({}, Date.now()) === "", "expected empty")
|
|
check("unlimited access omits the maximum",
|
|
Model.sendAccessLabel({ accessCount: 2, maxAccessCount: null }) === "2 views", "expected '2 views'")
|
|
check("a capped Send shows the maximum",
|
|
Model.sendAccessLabel({ accessCount: 2, maxAccessCount: 5 }) === "2 of 5 views", "expected '2 of 5 views'")
|
|
|
|
|
|
// --- no bw command may carry the session token in argv ----------------------
|
|
// /proc/<pid>/cmdline is world-readable on a default Linux install, and the
|
|
// token grants full access to the unlocked vault. It goes in BW_SESSION.
|
|
check("the session env var is BW_SESSION, which bw reads natively",
|
|
Model.sessionEnvVar() === "BW_SESSION", Model.sessionEnvVar())
|
|
|
|
const builders = [
|
|
["statusCommand", () => Model.statusCommand()],
|
|
["listCommand", () => Model.listCommand()],
|
|
["listFoldersCommand", () => Model.listFoldersCommand()],
|
|
["listOrganizationsCommand", () => Model.listOrganizationsCommand()],
|
|
["listSendsCommand", () => Model.listSendsCommand()],
|
|
["syncCommand", () => Model.syncCommand()],
|
|
["lockCommand", () => Model.lockCommand()],
|
|
["getItemCommand", () => Model.getItemCommand("id")],
|
|
["getTotpCommand", () => Model.getTotpCommand("id")],
|
|
["deleteItemCommand", () => Model.deleteItemCommand("id")],
|
|
["deleteSendCommand", () => Model.deleteSendCommand("id")],
|
|
["createFolderCommand", () => Model.createFolderCommand("f")],
|
|
["createItemCommand", () => Model.createItemCommand({ organizationId: null })],
|
|
["editItemCommand", () => Model.editItemCommand("id")],
|
|
["createSendCommand", () => Model.createSendCommand()],
|
|
]
|
|
for (const [name, build] of builders) {
|
|
const argv = build().join(" ")
|
|
check(`${name} passes no --session flag`, !argv.includes("--session"), argv)
|
|
}
|
|
|
|
|
|
// --- terminal login handoff -------------------------------------------------
|
|
const login = Model.terminalLoginCommand("login")[2]
|
|
const unlock = Model.terminalLoginCommand("unlock")[2]
|
|
const euLogin = Model.terminalLoginCommand("login", "https://vault.bitwarden.eu")[2]
|
|
check("terminal login runs in a terminal", login.includes("omarchy launch terminal"), login.slice(0, 80))
|
|
check("it falls back to a second terminal if the first is unavailable",
|
|
login.includes("alacritty"), login.slice(0, 80))
|
|
// --raw prints only the session key on stdout while prompts stay on stderr,
|
|
// which is what lets the key be captured without breaking the interactive login.
|
|
check("it captures the key with --raw", login.includes("--raw"), login.slice(0, 120))
|
|
// The panel already knows which state it is in, so the terminal does not spend
|
|
// a `bw status` round trip (~3.3s here) working it out before prompting.
|
|
check("login mode runs bw login", login.includes("bw login --raw") && !login.includes("bw unlock"), login.slice(0, 200))
|
|
check("unlock mode runs bw unlock", unlock.includes("bw unlock --raw") && !unlock.includes("bw login"), unlock.slice(0, 200))
|
|
check("neither mode probes with bw status",
|
|
!login.includes("bw status") && !unlock.includes("bw status"), "expected no status probe")
|
|
check("an unknown mode falls back to login",
|
|
Model.terminalLoginCommand("")[2].includes("bw login --raw"), "expected login")
|
|
check("terminal login configures an explicitly selected region before authenticating",
|
|
euLogin.includes("bw config server")
|
|
&& euLogin.includes("https://vault.bitwarden.eu")
|
|
&& euLogin.indexOf("bw config server") < euLogin.indexOf("bw login --raw"),
|
|
euLogin.slice(0, 300))
|
|
// Only the method name crosses the IPC boundary; the key never does.
|
|
check("a successful login reopens the panel",
|
|
login.includes("omarchy-shell io.github.elevate08.qs-bitwarden-cli open"), login.slice(0, 300))
|
|
check("the session key is not passed over IPC",
|
|
!login.includes("open $f") && !login.includes("open \"$f\""), login.slice(0, 300))
|
|
// The inner script appears twice -- once for the terminal, once for the
|
|
// alacritty fallback -- so count pauses against failure branches rather than
|
|
// assuming a single occurrence.
|
|
check("the user is only made to press a key when the login failed",
|
|
login.split("read -p").length === login.split("Not completed").length,
|
|
`${login.split("read -p").length - 1} pauses vs ${login.split("Not completed").length - 1} failure branches`)
|
|
check("a successful login closes the terminal on its own",
|
|
login.includes("Returning to the Bitwarden panel") && login.includes("sleep 1"),
|
|
"expected the success branch to close itself")
|
|
// The key is a secret at rest: tmpfs, user-only, gone when the session ends.
|
|
check("the handoff lives in XDG_RUNTIME_DIR, not on disk",
|
|
login.includes("XDG_RUNTIME_DIR"), login.slice(0, 120))
|
|
check("the handoff is created with a restrictive umask",
|
|
login.includes("umask 077") && login.includes("chmod 700"), login.slice(0, 200))
|
|
check("an incomplete login leaves nothing behind",
|
|
login.includes('rm -f'), login.slice(0, 300))
|
|
|
|
const read = Model.sessionHandoffReadCommand(true)[2]
|
|
check("the handoff is read once and removed with a byte limit",
|
|
read.includes("head -c") && read.includes("rm -f"), read)
|
|
check("an absent or empty handoff yields nothing",
|
|
read.includes("-s "), read)
|
|
|
|
// The panel parses whatever the file holds through the same extractor it uses
|
|
// for unlock output, so a stray newline or an `export BW_SESSION=` line is fine.
|
|
check("a bare key is extracted intact",
|
|
Model.extractSessionToken("abcdefghijklmnopqrstuvwxyz0123456789==") === "abcdefghijklmnopqrstuvwxyz0123456789==",
|
|
Model.extractSessionToken("abcdefghijklmnopqrstuvwxyz0123456789=="))
|
|
check("an export line is unwrapped",
|
|
Model.extractSessionToken('export BW_SESSION="tok3n-value-that-is-long-enough=="') === "tok3n-value-that-is-long-enough==",
|
|
Model.extractSessionToken('export BW_SESSION="tok3n-value-that-is-long-enough=="'))
|
|
|
|
console.log(`${pass} passed, ${failures.length} failed`)
|
|
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|