- 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
721 lines
42 KiB
JavaScript
721 lines
42 KiB
JavaScript
#!/usr/bin/env node
|
|
// The approval prompt is the one place a user is asked to authorise a
|
|
// signature, so what it shows has to be accurate about what the companion
|
|
// actually verified -- and what it did not. These tests cover the prompt's
|
|
// presentation, the deny/approve/grant control lines, the denial cooldown
|
|
// that stops a same-UID process reopening the panel forever, and the rule
|
|
// that no prompt is ever raised over a locked screen.
|
|
//
|
|
// node tests/ssh-agent-ui.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
|
|
const repoRoot = path.join(__dirname, "..")
|
|
const Model = {}
|
|
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
|
.replace(/^\.pragma library\s*$/m, "") + `
|
|
exports.parseAgentEvent = parseAgentEvent
|
|
exports.sshAgentApproveLine = sshAgentApproveLine
|
|
exports.sshAgentDenyLine = sshAgentDenyLine
|
|
exports.sshAgentUnlockCancelledLine = sshAgentUnlockCancelledLine
|
|
exports.sshAgentRevokeGrantLine = sshAgentRevokeGrantLine
|
|
exports.sshAgentRevokeGrantsLine = sshAgentRevokeGrantsLine
|
|
exports.sshAgentPromptView = sshAgentPromptView
|
|
exports.sshAgentGrantViews = sshAgentGrantViews
|
|
exports.sshAgentGrantsAt = sshAgentGrantsAt
|
|
exports.sshAgentDevelopmentHelperWarning = sshAgentDevelopmentHelperWarning
|
|
exports.sshAgentRoutingNotice = sshAgentRoutingNotice
|
|
exports.pluginDataRemoveCommand = pluginDataRemoveCommand
|
|
exports.parsePluginDataRemoval = parsePluginDataRemoval
|
|
exports.sshAgentShouldPrompt = sshAgentShouldPrompt
|
|
exports.sshAgentCooldownInitial = sshAgentCooldownInitial
|
|
exports.sshAgentCooldownAfter = sshAgentCooldownAfter
|
|
exports.sshAgentCooldownActive = sshAgentCooldownActive
|
|
exports.sshAgentCooldownStatus = sshAgentCooldownStatus
|
|
exports.sshAgentLoadingNote = sshAgentLoadingNote
|
|
exports.sshAgentOptionsLine = sshAgentOptionsLine
|
|
exports.sshAgentRequestDeadlineMs = sshAgentRequestDeadlineMs
|
|
exports.sshAgentEnqueuePrompt = sshAgentEnqueuePrompt
|
|
exports.sshAgentDequeuePrompt = sshAgentDequeuePrompt
|
|
exports.sshAgentRemovePrompt = sshAgentRemovePrompt
|
|
exports.sshAgentPendingCount = sshAgentPendingCount
|
|
exports.plainLabel = plainLabel
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
|
const eq = (label, actual, expected) =>
|
|
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The companion's new messages must survive the bounded reader
|
|
// -------------------------------------------------------------------------
|
|
|
|
for (const [type, body] of [
|
|
["unlock_required", { requestId: 41, reason: "sign", keyName: "Work", fingerprint: "SHA256:x", pid: 12, processPath: "/usr/bin/ssh" }],
|
|
["approval_required", { requestId: 42, keyId: "k", keyName: "Work", fingerprint: "SHA256:x", pid: 12, processPath: "/usr/bin/ssh", operation: "ssh-sign", forwarded: false, grantOffered: true }],
|
|
["request_cancelled", { requestId: 42, reason: "withdrawn" }],
|
|
["grants_changed", { grants: [] }]
|
|
]) {
|
|
const parsed = Model.parseAgentEvent(JSON.stringify(Object.assign({ v: 1, type: type }, body)))
|
|
eq(`${type} parses`, parsed.ok, true)
|
|
eq(`${type} keeps its type`, parsed.ok && parsed.message.type, type)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Control lines
|
|
// -------------------------------------------------------------------------
|
|
|
|
eq("approve once carries a zero window", Model.sshAgentApproveLine(42, 0),
|
|
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 0 }) + "\n")
|
|
eq("approve for a process carries its window", Model.sshAgentApproveLine(42, 120),
|
|
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 120 }) + "\n")
|
|
eq("a grant window is clamped to the documented cap", Model.sshAgentApproveLine(42, 99999),
|
|
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 900 }) + "\n")
|
|
eq("a negative window approves once instead", Model.sshAgentApproveLine(42, -5),
|
|
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 0 }) + "\n")
|
|
eq("deny is a versioned v1 line", Model.sshAgentDenyLine(42),
|
|
JSON.stringify({ v: 1, type: "deny", requestId: 42 }) + "\n")
|
|
eq("a dismissed unlock is reported as user-cancelled", Model.sshAgentUnlockCancelledLine(41),
|
|
JSON.stringify({ v: 1, type: "unlock_cancelled", requestId: 41, reason: "user-cancelled" }) + "\n")
|
|
// The companion cannot read shell.json, so the panel has to tell it.
|
|
eq("unlock-on-demand is sent to the companion", Model.sshAgentOptionsLine(true),
|
|
JSON.stringify({ v: 1, type: "options", unlockOnDemand: true }) + "\n")
|
|
eq("and its default off state is sent too", Model.sshAgentOptionsLine(false),
|
|
JSON.stringify({ v: 1, type: "options", unlockOnDemand: false }) + "\n")
|
|
eq("anything that is not true is off", Model.sshAgentOptionsLine(undefined),
|
|
JSON.stringify({ v: 1, type: "options", unlockOnDemand: false }) + "\n")
|
|
|
|
eq("a single grant is revoked by id", Model.sshAgentRevokeGrantLine(9),
|
|
JSON.stringify({ v: 1, type: "revoke_grant", grantId: 9 }) + "\n")
|
|
eq("an invalid request id yields no line", Model.sshAgentApproveLine("nope", 0), "")
|
|
eq("an invalid grant id yields no line", Model.sshAgentRevokeGrantLine(-1), "")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// What the prompt shows
|
|
// -------------------------------------------------------------------------
|
|
|
|
const request = {
|
|
v: 1, type: "approval_required", requestId: 42, keyId: "item-1",
|
|
keyName: "personal ed25519", fingerprint: "SHA256:9wKk2nQ8xR1vLm4pZc7dYtE0",
|
|
pid: 48213, processPath: "/usr/bin/ssh", operation: "ssh-sign",
|
|
forwarded: false, grantOffered: true
|
|
}
|
|
|
|
const view = Model.sshAgentPromptView(request, 120)
|
|
eq("the prompt names the key", view.keyName, "personal ed25519")
|
|
eq("the prompt shows the full fingerprint", view.fingerprint, "SHA256:9wKk2nQ8xR1vLm4pZc7dYtE0")
|
|
eq("the prompt shows the executable path", view.processPath, "/usr/bin/ssh")
|
|
eq("the prompt derives the process name from the path", view.processName, "ssh")
|
|
eq("the prompt shows the pid", view.pid, 48213)
|
|
eq("the prompt offers a grant", view.grantOffered, true)
|
|
check("the grant button states its window", /2m|120/.test(view.grantLabel), view.grantLabel)
|
|
// A grant covers one program, not one process (docs/decisions/0002-grant-scope.md).
|
|
// The button has to say so, or it promises a narrower thing than it does.
|
|
check("the grant button says what it actually covers",
|
|
/program/i.test(view.grantLabel) && !/this process/i.test(view.grantLabel), view.grantLabel)
|
|
|
|
// The companion verifies the peer UID and nothing else. Saying so on the
|
|
// prompt is the difference between context and a claim of identity.
|
|
check("the prompt says process details are not verified",
|
|
/not verified|reported/i.test(view.provenanceNote), view.provenanceNote)
|
|
check("the prompt never calls the process trusted or verified",
|
|
!/\b(verified|authenticated|trusted) (process|by)\b/i.test(view.provenanceNote), view.provenanceNote)
|
|
|
|
// A zero window means grants are off, so the button must not be offered.
|
|
const noGrant = Model.sshAgentPromptView(request, 0)
|
|
eq("a zero window offers no grant", noGrant.grantOffered, false)
|
|
const refusedGrant = Model.sshAgentPromptView(Object.assign({}, request, { grantOffered: false }), 120)
|
|
eq("a companion that offers no grant is respected", refusedGrant.grantOffered, false)
|
|
|
|
// Forwarding is rejected in v1; if one ever arrives it is called out, not
|
|
// shown as ordinary context.
|
|
const forwarded = Model.sshAgentPromptView(Object.assign({}, request, { forwarded: true }), 120)
|
|
check("a forwarded request is flagged", forwarded.forwardedWarning.length > 0, forwarded.forwardedWarning)
|
|
eq("a forwarded request offers no grant", forwarded.grantOffered, false)
|
|
eq("an ordinary request has no forwarding warning", view.forwardedWarning, "")
|
|
|
|
// A vault item's name is attacker-controllable by whoever shares the
|
|
// collection it came from, and the process path comes from outside too.
|
|
const hostile = Model.sshAgentPromptView(Object.assign({}, request, {
|
|
keyName: "<img src=x onerror=alert(1)>",
|
|
processPath: "/usr/bin/<b>ssh</b>"
|
|
}), 120)
|
|
check("a markup key name cannot reach a rich-text control",
|
|
Model.plainLabel(hostile.keyName).indexOf("<img") < 0, Model.plainLabel(hostile.keyName))
|
|
check("a hostile name is not silently dropped",
|
|
hostile.keyName.length > 0, hostile.keyName)
|
|
|
|
const huge = Model.sshAgentPromptView(Object.assign({}, request, {
|
|
keyName: "n".repeat(5000), processPath: "/" + "p".repeat(5000)
|
|
}), 120)
|
|
check("an absurd key name is bounded", huge.keyName.length <= 256, String(huge.keyName.length))
|
|
check("an absurd path is bounded", huge.processPath.length <= 512, String(huge.processPath.length))
|
|
|
|
// The panel's countdown has to agree with the companion's deadline, or it
|
|
// counts down to a moment nothing happens at. See
|
|
// docs/decisions/0003-request-deadline.md for the figure.
|
|
eq("the request deadline matches the companion's", Model.sshAgentRequestDeadlineMs(), 120000)
|
|
const agentSrc = fs.readFileSync(path.join(repoRoot, "agent", "src", "approvals.rs"), "utf8")
|
|
const agentDeadline = /pub const REQUEST_LIFETIME_MS: u64 = ([0-9_]+);/.exec(agentSrc)
|
|
check("the panel and the companion agree on it",
|
|
agentDeadline && Number(agentDeadline[1].replace(/_/g, "")) === Model.sshAgentRequestDeadlineMs(),
|
|
agentDeadline ? agentDeadline[1] : "REQUEST_LIFETIME_MS not found")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Grants
|
|
// -------------------------------------------------------------------------
|
|
|
|
const grants = Model.sshAgentGrantViews([
|
|
{ grantId: 9, keyName: "personal ed25519", fingerprint: "SHA256:x", pid: 48213, processPath: "/usr/bin/ssh", expiresInSec: 95 },
|
|
{ grantId: 10, keyName: "work rsa", fingerprint: "SHA256:y", pid: 5, processPath: "/usr/bin/git", expiresInSec: 0 }
|
|
])
|
|
eq("every grant is listed", grants.length, 2)
|
|
eq("a grant keeps its id", grants[0].grantId, 9)
|
|
eq("a grant names its process", grants[0].processName, "ssh")
|
|
check("a grant states its remaining time", /1m 35s|95/.test(grants[0].remainingLabel), grants[0].remainingLabel)
|
|
check("an expiring grant says so", grants[1].remainingLabel.length > 0, grants[1].remainingLabel)
|
|
check("no grant view carries key material",
|
|
grants.every(g => JSON.stringify(g).indexOf("PRIVATE") < 0), "leaked")
|
|
eq("a malformed grant list yields nothing", Model.sshAgentGrantViews(null).length, 0)
|
|
|
|
// A grant is announced once and then nothing is said until it changes, so the
|
|
// remaining time has to be re-derived rather than remembered. Without this the
|
|
// settings screen showed "1m 59s left" for the whole two minutes and then the
|
|
// row disappeared, having never counted down.
|
|
const announced = Model.sshAgentGrantViews(
|
|
[{ grantId: 9, keyName: "personal ed25519", fingerprint: "SHA256:x", pid: 48213, processPath: "/usr/bin/ssh", expiresInSec: 120 }],
|
|
10_000)
|
|
eq("an announced grant records when it expires", announced[0].expiresAtMs, 130_000)
|
|
const halfway = Model.sshAgentGrantsAt(announced, 70_000)
|
|
eq("the remaining time follows the clock", halfway[0].remainingSec, 60)
|
|
check("and the label follows it", /1m/.test(halfway[0].remainingLabel), halfway[0].remainingLabel)
|
|
eq("a lapsed grant leaves the list without waiting to be told",
|
|
Model.sshAgentGrantsAt(announced, 130_001).length, 0)
|
|
eq("a grant on its last second is still listed",
|
|
Model.sshAgentGrantsAt(announced, 129_500).length, 1)
|
|
eq("an unstamped view survives re-derivation rather than vanishing",
|
|
Model.sshAgentGrantsAt([{ grantId: 9, remainingLabel: "2m left" }], 70_000).length, 1)
|
|
eq("and so does every view before the first tick",
|
|
Model.sshAgentGrantsAt(announced, 0).length, 1)
|
|
eq("a malformed set re-derives to nothing", Model.sshAgentGrantsAt(null, 1).length, 0)
|
|
|
|
// A development helper is a state you can sit in for days without noticing,
|
|
// signing with a binary that has no recorded digest and no provenance. The
|
|
// warning has to say why that matters, and distinguish a shipped helper that
|
|
// was rejected from one that was simply never there.
|
|
const rejected = Model.sshAgentDevelopmentHelperWarning({ source: "development", checksum: "mismatch" })
|
|
check("a rejected shipped helper is named as the reason", /checksum/i.test(rejected), rejected)
|
|
const absent = Model.sshAgentDevelopmentHelperWarning({ source: "development", checksum: "unchecked" })
|
|
check("an absent one is not blamed on a checksum", !/checksum/i.test(absent), absent)
|
|
for (const [label, text] of [["rejected", rejected], ["absent", absent]]) {
|
|
check(`the ${label} warning says what is serving keys`, /locally built/i.test(text), text)
|
|
check(`the ${label} warning says what it lacks`, /provenance|digest/i.test(text), text)
|
|
check(`the ${label} warning says how to fix it`, /reinstall/i.test(text), text)
|
|
check(`the ${label} warning does not answer a user with a build command`,
|
|
!/build-agent|cargo/.test(text), text)
|
|
}
|
|
check("a missing helper record does not throw",
|
|
typeof Model.sshAgentDevelopmentHelperWarning(null) === "string", "threw or returned non-string")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Never prompt over a locked screen
|
|
// -------------------------------------------------------------------------
|
|
|
|
eq("an ordinary desktop prompts", Model.sshAgentShouldPrompt({ screenLocked: false }), true)
|
|
eq("a locked screen never prompts", Model.sshAgentShouldPrompt({ screenLocked: true }), false)
|
|
eq("an unknown screen state does not prompt", Model.sshAgentShouldPrompt(null), false)
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Denial cooldown
|
|
// -------------------------------------------------------------------------
|
|
|
|
let cool = Model.sshAgentCooldownInitial()
|
|
eq("nothing is on cooldown to begin with", Model.sshAgentCooldownActive(cool, 0), false)
|
|
|
|
cool = Model.sshAgentCooldownAfter(cool, "denied", 1000)
|
|
eq("one denial does not start a cooldown", Model.sshAgentCooldownActive(cool, 1000), false)
|
|
cool = Model.sshAgentCooldownAfter(cool, "denied", 2000)
|
|
eq("two consecutive denials start one", Model.sshAgentCooldownActive(cool, 2000), true)
|
|
eq("the cooldown ends on its own", Model.sshAgentCooldownActive(cool, 2000 + 10 * 60 * 1000), false)
|
|
|
|
// A timeout is a denial for this purpose: the user saw it and did nothing.
|
|
let timedOut = Model.sshAgentCooldownInitial()
|
|
timedOut = Model.sshAgentCooldownAfter(timedOut, "timeout", 0)
|
|
timedOut = Model.sshAgentCooldownAfter(timedOut, "timeout", 100)
|
|
eq("two timeouts also start a cooldown", Model.sshAgentCooldownActive(timedOut, 100), true)
|
|
|
|
// Approving clears the history: the user is engaging, not being pestered.
|
|
let mixed = Model.sshAgentCooldownInitial()
|
|
mixed = Model.sshAgentCooldownAfter(mixed, "denied", 0)
|
|
mixed = Model.sshAgentCooldownAfter(mixed, "approved", 100)
|
|
mixed = Model.sshAgentCooldownAfter(mixed, "denied", 200)
|
|
eq("an approval resets the denial run", Model.sshAgentCooldownActive(mixed, 200), false)
|
|
|
|
// An approval cannot end a cooldown that is already running -- the cooldown is
|
|
// precisely what stops the prompt an approval would answer. Only an explicit
|
|
// resume ends it early; otherwise a user who dismissed two prompts waits out
|
|
// the full five minutes with nothing they can do about it.
|
|
let stuck = Model.sshAgentCooldownInitial()
|
|
stuck = Model.sshAgentCooldownAfter(stuck, "denied", 0)
|
|
stuck = Model.sshAgentCooldownAfter(stuck, "denied", 100)
|
|
eq("two denials leave a cooldown running", Model.sshAgentCooldownActive(stuck, 100), true)
|
|
stuck = Model.sshAgentCooldownAfter(stuck, "resumed", 200)
|
|
eq("an explicit resume ends it at once", Model.sshAgentCooldownActive(stuck, 200), false)
|
|
eq("and clears the run behind it, so one later denial does not re-arm it",
|
|
Model.sshAgentCooldownActive(Model.sshAgentCooldownAfter(stuck, "denied", 300), 300), false)
|
|
|
|
// Nothing the requesting process does may end a cooldown, or prolong one: the
|
|
// suppressed path answers the client itself and records no outcome, so only
|
|
// prompts a person actually saw ever feed the run.
|
|
let unattended = Model.sshAgentCooldownInitial()
|
|
unattended = Model.sshAgentCooldownAfter(unattended, "denied", 0)
|
|
unattended = Model.sshAgentCooldownAfter(unattended, "denied", 100)
|
|
eq("an unanswered request leaves the window where it was",
|
|
Model.sshAgentCooldownAfter(unattended, "withdrawn", 200).untilMs, unattended.untilMs)
|
|
eq("and the cooldown lapses on its own",
|
|
Model.sshAgentCooldownActive(unattended, 100 + 5 * 60 * 1000), false)
|
|
|
|
// A cooldown that fails signatures silently is worse than the pestering it
|
|
// prevents: SSH just stops working for five minutes with no explanation
|
|
// anywhere. It has to say so, and say when it lifts.
|
|
let cooled = Model.sshAgentCooldownInitial()
|
|
const quiet = Model.sshAgentCooldownStatus(cooled, 0)
|
|
eq("nothing is reported while no cooldown is running", quiet.active, false)
|
|
eq("a quiet cooldown has no message", quiet.message, "")
|
|
|
|
cooled = Model.sshAgentCooldownAfter(cooled, "denied", 0)
|
|
cooled = Model.sshAgentCooldownAfter(cooled, "denied", 0)
|
|
const cooling = Model.sshAgentCooldownStatus(cooled, 0)
|
|
eq("an active cooldown is reported", cooling.active, true)
|
|
check("it says SSH requests are being refused",
|
|
/refus|declin/i.test(cooling.message), cooling.message)
|
|
check("it says when it lifts", /\d/.test(cooling.message), cooling.message)
|
|
eq("it reports the remaining time", cooling.remainingSec, 300)
|
|
check("the remaining time counts down",
|
|
Model.sshAgentCooldownStatus(cooled, 60000).remainingSec === 240,
|
|
String(Model.sshAgentCooldownStatus(cooled, 60000).remainingSec))
|
|
eq("it clears itself when the window passes",
|
|
Model.sshAgentCooldownStatus(cooled, 5 * 60 * 1000).active, false)
|
|
check("the status never names a key or a process",
|
|
cooling.message.indexOf("ssh-") < 0 && cooling.message.indexOf("/usr/") < 0, cooling.message)
|
|
|
|
// Unlocking runs one `bw list items`, which takes seconds on a real vault.
|
|
// The request that triggered the unlock is held across it, so without a
|
|
// loading state the user unlocks and then watches nothing happen.
|
|
const waiting = Model.sshAgentPromptView(Object.assign({}, request, { type: "unlock_required" }), 120)
|
|
check("a held request can say it is still loading",
|
|
typeof Model.sshAgentLoadingNote === "function", "no loading note is available")
|
|
if (typeof Model.sshAgentLoadingNote === "function") {
|
|
const note = Model.sshAgentLoadingNote()
|
|
check("the loading note says keys are on the way", /load/i.test(note), note)
|
|
check("the loading note does not promise it is instant",
|
|
!/instant|immediat/i.test(note), note)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The panel wiring
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Every file the SSH markup lives in: the settings sections and the approval
|
|
// screen have their own, and Panel.qml keeps the rest. Reading only the first
|
|
// would leave every "this must NOT appear" check below passing on content that
|
|
// had simply moved.
|
|
const sshUiFiles = [
|
|
"Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml",
|
|
"SshApprovalPopup.qml", "SshUnlockScreen.qml"
|
|
]
|
|
const panelSrc = sshUiFiles
|
|
.map(file => fs.existsSync(path.join(repoRoot, file))
|
|
? fs.readFileSync(path.join(repoRoot, file), "utf8") : "")
|
|
.join("\n")
|
|
const approvalSrc = fs.readFileSync(path.join(repoRoot, "SshApprovalScreen.qml"), "utf8")
|
|
const settingsSrc = fs.readFileSync(path.join(repoRoot, "SshAgentSettings.qml"), "utf8")
|
|
const popupSrc = fs.existsSync(path.join(repoRoot, "SshApprovalPopup.qml"))
|
|
? fs.readFileSync(path.join(repoRoot, "SshApprovalPopup.qml"), "utf8") : ""
|
|
const unlockSrc = fs.existsSync(path.join(repoRoot, "SshUnlockScreen.qml"))
|
|
? fs.readFileSync(path.join(repoRoot, "SshUnlockScreen.qml"), "utf8") : ""
|
|
|
|
// plainLabel() wraps its argument in a span when the text contains markup
|
|
// characters, which a PlainText control then renders literally. The field is
|
|
// matched with whatever object it hangs off, because the settings sections
|
|
// reach the panel as `panel` and the screens as `root`: pinning the prefix
|
|
// would let these checks pass on content that had only moved between files.
|
|
for (const field of [
|
|
"sshAgentVersion",
|
|
"modelData.keyName",
|
|
"modelData.processName",
|
|
"sshUnlockRequest.keyName",
|
|
"sshUnlockRequest.processName",
|
|
"sshPrompt.keyName",
|
|
"sshPrompt.processName",
|
|
"sshPrompt.processPath",
|
|
"sshRouting.owner"
|
|
]) {
|
|
const wrapped = new RegExp(
|
|
"plainLabel\\(\\s*(?:root|panel|section\\.panel)?\\.?"
|
|
+ field.replace(/\./g, "\\.") + "\\s*\\)")
|
|
check("SSH PlainText labels do not receive rich-text wrappers for " + field,
|
|
!wrapped.test(panelSrc), field)
|
|
}
|
|
|
|
check("there is a dedicated approval screen",
|
|
/currentScreen === "sshApproval"/.test(panelSrc), "no sshApproval screen")
|
|
check("an approval_required message raises the prompt",
|
|
/message\.type === "approval_required"[\s\S]{0,600}?showSshApproval\(message\)/.test(panelSrc),
|
|
"approval_required never raises the prompt")
|
|
// Opening the panel sends an unlocked one to the item list, so a prompt that
|
|
// claimed the screen first would be silently undone -- live state, blank
|
|
// screen. Both halves of that ordering are pinned here because the failure is
|
|
// invisible: everything reports healthy while nothing is drawn.
|
|
check("the legacy panel is opened before its approval screen is claimed",
|
|
/function showSshApproval\(message\)[\s\S]{0,1200}?sshAgentApprovalPopup[\s\S]{0,400}?return[\s\S]{0,600}?root\.open\(\)[\s\S]{0,200}?currentScreen = "sshApproval"/.test(panelSrc),
|
|
"the screen is claimed before opening, so opening resets it")
|
|
// Pinned on the ordering rather than on a character distance: what matters is
|
|
// that a live prompt claims the screen and returns before the branch that
|
|
// would send an unlocked panel to the item list, not how much housekeeping
|
|
// happens above it.
|
|
const openedBody = panelSrc.slice(panelSrc.indexOf("function onPanelOpened()"),
|
|
panelSrc.indexOf("function onPanelClosed") > 0
|
|
? panelSrc.indexOf("function onPanelClosed")
|
|
: panelSrc.indexOf("function onPanelOpened()") + 2000)
|
|
check("opening the panel does not discard a live request",
|
|
/if \(sshPrompt\)[\s\S]{0,160}?currentScreen = "sshApproval"[\s\S]{0,40}?return/.test(openedBody)
|
|
&& openedBody.indexOf("sshPrompt") < openedBody.indexOf('status === "unlocked"'),
|
|
"onPanelOpened resets away from a live prompt")
|
|
check("a withdrawn prompt counts toward the cooldown",
|
|
/message\.reason !== "released"[\s\S]{0,200}?sshAgentCooldownAfter\(root\.sshCooldown, "timeout"/.test(panelSrc),
|
|
"an unanswered prompt never feeds the cooldown")
|
|
|
|
// The panel resets currentScreen in several of its own flows -- opening the
|
|
// panel, finishing an unlock -- each of which silently dropped a live prompt
|
|
// before. Screen visibility binds to activeScreen, which a live request wins,
|
|
// so no later assignment can hide a question a client is blocked on.
|
|
check("a live prompt outranks navigation state",
|
|
/readonly property string activeScreen: sshPrompt !== null && !sshAgentApprovalPopup \? "sshApproval" : currentScreen/.test(panelSrc),
|
|
"no activeScreen; a stray currentScreen assignment can hide the prompt")
|
|
check("no screen visibility still binds to currentScreen directly",
|
|
panelSrc.split("\n").filter(l => l.trim().startsWith("visible:") && l.includes("root.currentScreen")).length === 0,
|
|
panelSrc.split("\n").filter(l => l.trim().startsWith("visible:") && l.includes("root.currentScreen")).join(" | "))
|
|
|
|
check("raising the prompt switches to the approval screen",
|
|
/function showSshApproval\(message\)[\s\S]{0,1200}?currentScreen = "sshApproval"/.test(panelSrc),
|
|
"the prompt never opens the approval screen")
|
|
check("a request that cannot prompt is denied rather than left hanging",
|
|
/message\.type === "approval_required"[\s\S]{0,400}?sshAgentMayPrompt\(\)[\s\S]{0,200}?sshAgentDenyLine/.test(panelSrc),
|
|
"a suppressed request is not answered")
|
|
// A prompt that opened the panel on the user's behalf should hand the desktop
|
|
// back when it is answered -- approved or denied alike. A panel the user had
|
|
// already opened is theirs, so answering returns them to the screen they were
|
|
// on rather than closing it under them.
|
|
check("answering a prompt that opened the panel closes it again",
|
|
/function dismissSshApproval\(\)[\s\S]{0,900}?openedForThis && root\.opened\) root\.close\(\)/.test(panelSrc),
|
|
"the panel stays open after an answer it opened itself for")
|
|
check("whether the panel was already open is captured before opening it",
|
|
/function showSshApproval\(message\)[\s\S]{0,900}?sshPromptOpenedPanel = !root\.opened/.test(panelSrc),
|
|
"nothing records whether the request opened the panel")
|
|
check("a panel the user already had open is restored, not closed",
|
|
/function dismissSshApproval\(\)[\s\S]{0,900}?screenBeforeSshApproval/.test(panelSrc),
|
|
"answering does not restore the previous screen")
|
|
|
|
check("a withdrawn request takes its prompt down",
|
|
/message\.type === "request_cancelled"[\s\S]{0,900}?dismissSshApproval\(\)/.test(panelSrc),
|
|
"request_cancelled is ignored")
|
|
// The approval decision depends on the key identity and the requesting
|
|
// program, both known before the vault read finishes. Waiting for the read
|
|
// and only then asking is delay with nothing behind it.
|
|
check("unlocking promotes the held request straight to an approval",
|
|
/function promoteUnlockToApproval\(\)[\s\S]{0,600}?showSshApproval\(raw\)/.test(panelSrc),
|
|
"unlocking never promotes the held request")
|
|
check("the promotion happens as soon as the vault unlocks",
|
|
/onStatusChanged:[\s\S]{0,200}?promoteUnlockToApproval\(\)/.test(panelSrc),
|
|
"nothing promotes on unlock")
|
|
// The panel root reaches the screens as `root` and the extracted files as
|
|
// `panel`, so the object is matched either way -- a check pinned to one of
|
|
// them starts passing or failing on which file the markup sits in.
|
|
check("the approval prompt says keys are still loading",
|
|
/visible: (?:root|panel)\.sshAgentLoadActive[\s\S]{0,200}?sshAgentLoadingNote\(\)/.test(panelSrc),
|
|
"the prompt does not say the keys are still on their way")
|
|
|
|
check("the held request stays on screen while keys load",
|
|
/sshAgentLoadingNote\(\)/.test(panelSrc), "nothing tells the user keys are loading")
|
|
check("the loading state is driven by the load actually being in flight",
|
|
/sshUnlockRequest[\s\S]{0,600}?sshAgentLoadActive|sshAgentLoadActive[\s\S]{0,600}?sshUnlockRequest/.test(panelSrc),
|
|
"the loading state is not tied to a real load")
|
|
|
|
check("the setting reaches the companion on handshake and on change",
|
|
/onSshAgentUnlockOnDemandChanged: sendSshAgentOptions\(\)/.test(panelSrc)
|
|
&& /if \(sshAgentGateOpen\) sendSshAgentOptions\(\)/.test(panelSrc),
|
|
"unlock-on-demand never reaches the companion")
|
|
check("an identity listing is not promoted into an approval",
|
|
/reason === "list-identities"/.test(panelSrc),
|
|
"a listing would be turned into a signature approval")
|
|
|
|
check("an unlock request is shown with its context",
|
|
/message\.type === "unlock_required"/.test(panelSrc), "unlock_required is ignored")
|
|
check("grants are tracked from the companion",
|
|
/message\.type === "grants_changed"/.test(panelSrc), "grants_changed is ignored")
|
|
|
|
check("denying is wired to the deny line",
|
|
/sshAgentDenyLine\(/.test(panelSrc), "nothing sends deny")
|
|
check("approving once is wired",
|
|
/sshAgentApproveLine\(/.test(panelSrc), "nothing sends approve")
|
|
check("grants can be revoked individually and together",
|
|
/sshAgentRevokeGrantLine\(/.test(panelSrc) && /sshAgentRevokeGrantsLine\(/.test(panelSrc),
|
|
"grant revocation is not wired")
|
|
|
|
check("a locked screen suppresses the prompt",
|
|
/sshAgentShouldPrompt\(/.test(panelSrc), "the screen-lock rule is not applied")
|
|
check("the cooldown is surfaced in the panel rather than failing silently",
|
|
/sshAgentCooldownStatus\(/.test(panelSrc), "the cooldown is never shown to the user")
|
|
check("entering the cooldown is announced once, not on every refusal",
|
|
/sshCooldownAnnounced/.test(panelSrc), "nothing announces the cooldown")
|
|
|
|
check("a request the cooldown refuses does not feed the cooldown",
|
|
/!sshAgentMayPrompt\(\)\)\s*\{\s*sshAgentWrite\(Model\.sshAgentDenyLine\(message\.requestId\)\)\s*return/.test(panelSrc),
|
|
"a suppressed request records an outcome, so a busy process can hold the cooldown open")
|
|
// SSH_AUTH_SOCK is fixed at login, so it says what routing *was*. A session
|
|
// that started routed keeps reporting "matches" after the file is deleted,
|
|
// which is precisely the window in which a warning would still be useful --
|
|
// the notice therefore has to read the file, not the environment.
|
|
const routed = { state: "matches" }
|
|
eq("a deleted fragment is caught even while this session still points here",
|
|
Model.sshAgentRoutingNotice({ state: "absent" }, routed).urgent, true)
|
|
check("and it says the next login is what breaks",
|
|
/next login/.test(Model.sshAgentRoutingNotice({ state: "absent" }, routed).text),
|
|
Model.sshAgentRoutingNotice({ state: "absent" }, routed).text)
|
|
eq("a foreign file is called out too",
|
|
Model.sshAgentRoutingNotice({ state: "foreign" }, routed).urgent, true)
|
|
eq("a managed fragment in a routed session says nothing",
|
|
Model.sshAgentRoutingNotice({ state: "managed" }, routed).text, "")
|
|
check("a managed fragment in an unrouted session explains the wait",
|
|
/next login/.test(Model.sshAgentRoutingNotice({ state: "managed" }, { state: "elsewhere" }).text),
|
|
"a freshly written fragment does not explain why nothing changed yet")
|
|
eq("and that is information, not a fault",
|
|
Model.sshAgentRoutingNotice({ state: "managed" }, { state: "elsewhere" }).urgent, false)
|
|
for (const quiet of ["unknown", "no-home"]) {
|
|
eq(`a ${quiet} fragment leaves the routing section to explain itself`,
|
|
Model.sshAgentRoutingNotice({ state: quiet }, routed).text, "")
|
|
}
|
|
eq("a missing record says nothing rather than throwing",
|
|
Model.sshAgentRoutingNotice(null, null).text, "")
|
|
|
|
check("the routing notice precedes the routing section",
|
|
/sshRoutingNotice\.text[\s\S]{0,500}?text: "CLIENT ROUTING"/.test(panelSrc),
|
|
"the routing notice does not precede the routing section")
|
|
check("re-enabling the agent restores the routing file it removed on disable",
|
|
/uwsmRestorePending = true[\s\S]{0,1200}?function applyUwsmRestore\(\)[\s\S]{0,600}?beginUwsmSetup\(\)/.test(panelSrc),
|
|
"disabling removes the routing file and enabling never puts it back")
|
|
check("but never over a file it did not write, or another session's agent",
|
|
/uwsmFragment\.state !== "absent" \|\| sshRouting\.state === "elsewhere"[\s\S]{0,40}?return/.test(panelSrc),
|
|
"the restore overrules a foreign routing file or an existing agent")
|
|
check("and routing is reachable before the approvals list",
|
|
panelSrc.indexOf('text: "CLIENT ROUTING"') < panelSrc.indexOf('text: "ACTIVE APPROVALS"'),
|
|
"approvals still push routing down the screen")
|
|
|
|
// `omarchy plugin remove` has no uninstall hook, so the last moment this code
|
|
// can run is while the plugin is still installed. What it misses becomes a
|
|
// command the user has to type from the README.
|
|
const wipe = Model.pluginDataRemoveCommand().join(" ")
|
|
for (const [what, needle] of [
|
|
["the keyring entries", "secret-tool clear service qs-bitwarden-cli"],
|
|
["the learned suggestions", "XDG_STATE_HOME"],
|
|
["the exported public keys", "XDG_DATA_HOME"]
|
|
]) check(`removal clears ${what}`, wipe.indexOf(needle) >= 0, wipe)
|
|
check("and never logs the vault out on its own",
|
|
wipe.indexOf("bw logout") < 0 && wipe.indexOf("bw ") < 0, wipe)
|
|
check("nor touches the shell's own settings file",
|
|
wipe.indexOf("shell.json") < 0, wipe)
|
|
|
|
const wiped = Model.parsePluginDataRemoval(0, "removed keyring state data")
|
|
eq("a full removal reports success", wiped.ok, true)
|
|
check("and names what went", /keyring|suggestions|public keys/.test(wiped.message), wiped.message)
|
|
check("and says the vault survived it", /vault/i.test(wiped.message), wiped.message)
|
|
eq("nothing to remove is still a success",
|
|
Model.parsePluginDataRemoval(0, "removed").ok, true)
|
|
check("and says so plainly",
|
|
/nothing/i.test(Model.parsePluginDataRemoval(0, "removed").message),
|
|
Model.parsePluginDataRemoval(0, "removed").message)
|
|
eq("a missing HOME is a failure, not a silent no-op",
|
|
Model.parsePluginDataRemoval(3, "").ok, false)
|
|
eq("and so is any other non-zero exit", Model.parsePluginDataRemoval(1, "").ok, false)
|
|
|
|
check("removing plugin data is confirmed before it happens",
|
|
/pluginDataConfirmPending = true[\s\S]{0,200}?return/.test(panelSrc),
|
|
"the first click wipes stored data with no confirmation")
|
|
check("and a cleared keyring is not still believed to hold a password",
|
|
/parsePluginDataRemoval[\s\S]{0,400}?fingerprintStored = false/.test(panelSrc),
|
|
"the panel still thinks a deleted master password is stored")
|
|
|
|
// A pid is noise on a prompt: it is gone by the time anyone could look it up,
|
|
// and it is deliberately not part of what a grant matches on -- so showing it
|
|
// implies a scope the approval does not have.
|
|
for (const [what, src] of [["the approval prompt", approvalSrc], ["the settings screen", settingsSrc]])
|
|
check(`${what} does not show a pid`, !/\bpid\b/.test(src), `${what} still renders a pid`)
|
|
check("the unlock prompt does not either", !/sshUnlockRequest\.pid/.test(panelSrc),
|
|
"the unlock prompt still renders a pid")
|
|
|
|
check("a development helper is called out wherever the user is",
|
|
/sshAgentHelper\.source === "development"[\s\S]{0,900}?sshAgentDevelopmentHelperWarning\(/.test(panelSrc),
|
|
"nothing warns that an unverified helper is serving keys")
|
|
check("the diagnostics say which helper is running and whether it was verified",
|
|
/helperSource: root\.sshAgentHelper\.source[\s\S]{0,200}?helperChecksum: root\.sshAgentHelper\.checksum/.test(panelSrc),
|
|
"sshAgentStatus cannot tell a shipped helper from a local build")
|
|
check("and what the panel believes about client routing",
|
|
/routingFragment: root\.uwsmFragment\.state[\s\S]{0,200}?routingNotice: root\.sshRoutingNotice\.text !== ""/.test(panelSrc),
|
|
"routing state cannot be read without squinting at the panel")
|
|
check("and why inspection rejected one, which errorCode never carries",
|
|
/helperState: root\.sshAgentHelper\.state/.test(panelSrc),
|
|
"sshAgentStatus reports an error state without naming it")
|
|
check("a running cooldown can be ended from the panel",
|
|
/function resumeSshSigning\(\)[\s\S]{0,300}?sshAgentCooldownAfter\(root\.sshCooldown, "resumed"/.test(panelSrc),
|
|
"nothing ends the cooldown early, so it cannot be escaped")
|
|
check("and the control that does it sits on the banner explaining the outage",
|
|
/sshCooldownStatus\.active[\s\S]{0,1600}?resumeSshSigning\(\)/.test(panelSrc),
|
|
"the resume control is not on the cooldown banner")
|
|
check("repeated denials enter the cooldown",
|
|
/sshAgentCooldownAfter\(/.test(panelSrc) && /sshAgentCooldownActive\(/.test(panelSrc),
|
|
"the cooldown is not applied")
|
|
check("escape denies rather than silently dismissing",
|
|
/sshApproval[\s\S]{0,900}?denySshRequest\(/.test(panelSrc), "escape does not deny")
|
|
check("the key name is rendered literally by a PlainText control",
|
|
/Text\s*\{[\s\S]{0,180}?textFormat:\s*Text\.PlainText[\s\S]{0,180}?(?:root|panel)\.sshPrompt\.keyName(?![A-Za-z0-9_])/
|
|
.test(panelSrc),
|
|
"the key name is not pinned to plain text")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Opt-in centered approval surface
|
|
// -------------------------------------------------------------------------
|
|
|
|
check("the panel reads the centered popup setting",
|
|
/readonly property bool sshAgentApprovalPopup:[^\n]*boolSetting\("sshAgentApprovalPopup"/.test(panelSrc),
|
|
"sshAgentApprovalPopup never reaches Panel.qml")
|
|
check("the popup is an overlay layer surface centered independently of the bar",
|
|
/PanelWindow\s*\{/.test(popupSrc)
|
|
&& /anchors\s*\{\s*top:\s*true\s*bottom:\s*true\s*left:\s*true\s*right:\s*true/.test(popupSrc)
|
|
&& /WlrLayer\.Overlay/.test(popupSrc)
|
|
&& /ExclusionMode\.Ignore/.test(popupSrc)
|
|
&& /namespace:\s*"qs-bitwarden-ssh-approval"/.test(popupSrc),
|
|
"the popup is not a full-screen, non-exclusive overlay layer surface")
|
|
check("the popup follows the panel's monitor",
|
|
/screen:[^\n]*anchorItem\.QsWindow\.window\.screen/.test(popupSrc),
|
|
"the popup has no screen affinity")
|
|
check("the popup exists only for opted-in pending SSH work",
|
|
/sshAgentApprovalPopup\s*&&\s*\(panel\.sshPrompt !== null \|\| panel\.sshUnlockRequest !== null\)/.test(popupSrc),
|
|
"the popup is not gated by both the setting and a pending request")
|
|
check("popup mode leaves the anchored panel closed for approvals",
|
|
/function showSshApproval\(message\)[\s\S]{0,900}?if \(root\.sshAgentApprovalPopup\)[\s\S]{0,240}?return/.test(panelSrc),
|
|
"showSshApproval always opens the panel")
|
|
check("popup mode leaves the anchored panel closed for unlock requests",
|
|
/message\.type === "unlock_required"[\s\S]{0,1400}?if \(root\.sshAgentApprovalPopup\)[\s\S]{0,240}?return/.test(panelSrc),
|
|
"unlock_required always opens the panel")
|
|
check("changing presentation mode cannot strand a live request off-screen",
|
|
/onSshAgentApprovalPopupChanged:[\s\S]{0,900}?sshPrompt \|\| root\.sshUnlockRequest[\s\S]{0,900}?root\.open\(\)/.test(panelSrc),
|
|
"turning popup mode off during a request leaves no visible approval surface")
|
|
check("the popup moves from unlock to approval without changing windows",
|
|
/SshUnlockScreen\s*\{/.test(popupSrc) && /SshApprovalScreen\s*\{/.test(popupSrc),
|
|
"unlock and approval are not hosted by one popup")
|
|
check("the popup unlock screen names the prerequisite",
|
|
/vault needs to be unlocked first/i.test(unlockSrc),
|
|
"the popup does not explain why it appeared")
|
|
check("the popup can submit every configured unlock method",
|
|
/unlockVault\(/.test(unlockSrc)
|
|
&& /submitPinUnlock\(/.test(unlockSrc)
|
|
&& /startFingerprintUnlock\(/.test(unlockSrc),
|
|
"password, PIN, or fingerprint is missing from the popup")
|
|
check("background click and Escape explicitly deny the pending request",
|
|
/MouseArea[\s\S]{0,500}?onClicked:\s*popup\.panel\.denySshRequest\(\)/.test(popupSrc)
|
|
&& /Qt\.Key_Escape[\s\S]{0,160}?denySshRequest\(\)/.test(popupSrc),
|
|
"the modal can disappear without answering the helper")
|
|
check("approval defaults keyboard focus to Deny",
|
|
/id:\s*denyButton/.test(approvalSrc)
|
|
&& /function focusDefault\(\)[\s\S]{0,120}?denyButton\.forceActiveFocus\(\)/.test(approvalSrc),
|
|
"an approval can receive accidental affirmative focus")
|
|
check("hidden panel fields cannot steal focus from the popup",
|
|
/function focusAppropriateField\(\)[\s\S]{0,120}?if \(sshApprovalPopupOpen\) return/.test(panelSrc),
|
|
"status and unlock handlers can focus an input in the closed panel")
|
|
check("approval actions opt into keyboard focus",
|
|
(approvalSrc.match(/focusable:\s*true/g) || []).length >= 2,
|
|
"approval buttons cannot be reached by Tab")
|
|
check("popup unlock is accepted while the anchored panel is closed",
|
|
/readonly property bool sshAuthSurfaceActive:\s*opened \|\| sshApprovalPopupOpen/.test(panelSrc)
|
|
&& /function prepareUnlock\(\)[\s\S]{0,180}?!sshAuthSurfaceActive/.test(panelSrc),
|
|
"unlock handlers still require root.opened")
|
|
check("closing the transient popup clears authentication state",
|
|
/function clearSshPopupUnlockState\(\)[\s\S]{0,700}?masterPassword = ""/.test(panelSrc)
|
|
&& /function dismissSshApproval\(\)[\s\S]{0,900}?clearSshPopupUnlockState\(\)/.test(panelSrc),
|
|
"password/PIN state can survive a dismissed popup")
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Concurrent request queueing
|
|
// -------------------------------------------------------------------------
|
|
|
|
let q = []
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 1, keyName: "key1" }, 4)
|
|
eq("enqueues first item", q.length, 1)
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 2, keyName: "key2" }, 4)
|
|
eq("enqueues second item", q.length, 2)
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 2, keyName: "key2" }, 4)
|
|
eq("ignores duplicate requestId", q.length, 2)
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 3 }, 4)
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 4 }, 4)
|
|
q = Model.sshAgentEnqueuePrompt(q, { requestId: 5 }, 4)
|
|
eq("respects queue capacity cap", q.length, 4)
|
|
|
|
eq("pending count includes active and queued", Model.sshAgentPendingCount({ requestId: 0 }, q), 5)
|
|
eq("pending count with no active", Model.sshAgentPendingCount(null, q), 4)
|
|
eq("pending count with no queue", Model.sshAgentPendingCount({ requestId: 0 }, []), 1)
|
|
|
|
let deq = Model.sshAgentDequeuePrompt(q)
|
|
eq("dequeues first item", deq.next.requestId, 1)
|
|
eq("remaining queue length is decremented", deq.remaining.length, 3)
|
|
|
|
let emptyDeq = Model.sshAgentDequeuePrompt([])
|
|
eq("empty dequeue next is null", emptyDeq.next, null)
|
|
eq("empty dequeue remaining is empty", emptyDeq.remaining.length, 0)
|
|
|
|
let removed = Model.sshAgentRemovePrompt(q, 3)
|
|
eq("removes targeted requestId", removed.length, 3)
|
|
eq("requestId 3 is absent", removed.some(x => x.requestId === 3), false)
|
|
|
|
check("the panel declares an SSH prompt queue",
|
|
/property var sshPromptQueue:\s*\[\]/.test(panelSrc),
|
|
"sshPromptQueue is missing from Panel.qml")
|
|
check("the panel declares total pending counts",
|
|
/readonly property int sshPendingCount:/.test(panelSrc)
|
|
&& /readonly property int sshUnlockPendingCount:/.test(panelSrc),
|
|
"sshPendingCount or sshUnlockPendingCount is missing")
|
|
check("multiple concurrent approval requests are queued",
|
|
/root\.sshPromptQueue = Model\.sshAgentEnqueuePrompt\(root\.sshPromptQueue, message, 4\)/.test(panelSrc),
|
|
"concurrent approvals are not queued")
|
|
check("multiple concurrent unlock requests are queued",
|
|
/root\.sshUnlockQueue = Model\.sshAgentEnqueuePrompt\(root\.sshUnlockQueue, message, 4\)/.test(panelSrc),
|
|
"concurrent unlocks are not queued")
|
|
check("advancing an approval dequeues the next prompt",
|
|
/function advanceSshPrompt\(\)[\s\S]{0,400}?Model\.sshAgentDequeuePrompt\(root\.sshPromptQueue\)/.test(panelSrc),
|
|
"advanceSshPrompt does not dequeue from sshPromptQueue")
|
|
check("advancing an unlock dequeues the next unlock request",
|
|
/function advanceSshUnlock\(\)[\s\S]{0,400}?Model\.sshAgentDequeuePrompt\(root\.sshUnlockQueue\)/.test(panelSrc),
|
|
"advanceSshUnlock does not dequeue from sshUnlockQueue")
|
|
check("deny all rejects active and queued requests",
|
|
/function denyAllSshRequests\(\)[\s\S]{0,900}?sshPromptQueue[\s\S]{0,600}?sshUnlockQueue[\s\S]{0,600}?dismissSshApproval\(\)/.test(panelSrc),
|
|
"denyAllSshRequests is missing or does not clear queues")
|
|
check("approval screen displays 1 of N when multiple requests are queued",
|
|
/text:\s*"1 of "\s*\+\s*panel\.sshPendingCount/.test(approvalSrc),
|
|
"approval screen does not display queue counter")
|
|
check("approval screen provides a Deny all button when multiple requests exist",
|
|
/text:\s*"Deny all \("\s*\+\s*panel\.sshPendingCount\s*\+\s*"\)"/.test(approvalSrc),
|
|
"approval screen is missing Deny all button")
|
|
check("popup accepts Shift+Escape to deny all requests",
|
|
/event\.modifiers\s*&\s*Qt\.ShiftModifier[\s\S]{0,120}?popup\.panel\.denyAllSshRequests\(\)/.test(popupSrc),
|
|
"popup does not handle Shift+Escape for deny all")
|
|
|
|
if (failures.length) {
|
|
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
|
failures.forEach(f => console.error(` FAIL ${f}`))
|
|
process.exit(1)
|
|
}
|
|
console.log(`ssh-agent-ui: ${pass} passed`)
|