- 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
463 lines
24 KiB
JavaScript
463 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
// The item detail view is built from what `bw list items` already returned
|
|
// rather than from a second `bw get item`. That is only correct if the two
|
|
// produce the same detail, so that equivalence is the property under test.
|
|
//
|
|
// node tests/items.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.parseItems = parseItems
|
|
exports.parseItemDetail = parseItemDetail
|
|
exports.itemDetailFromObject = itemDetailFromObject
|
|
exports.itemTypeGlyph = itemTypeGlyph
|
|
exports.parseSanitizedItems = parseSanitizedItems
|
|
exports.filterItems = filterItems
|
|
exports.buildCreatePayload = buildCreatePayload
|
|
exports.buildEditPayload = buildEditPayload
|
|
exports.matchesQuery = matchesQuery
|
|
exports.createItemCommand = createItemCommand
|
|
exports.spliceSavedItem = spliceSavedItem
|
|
exports.optimisticItem = optimisticItem
|
|
exports.replaceItemById = replaceItemById
|
|
exports.findItemById = findItemById
|
|
exports.pendingItemId = pendingItemId
|
|
exports.isPendingItemId = isPendingItemId
|
|
exports.savedUnsanitizedMarker = savedUnsanitizedMarker
|
|
exports.identityFullName = identityFullName
|
|
exports.getItemCommand = getItemCommand
|
|
exports.editItemCommand = editItemCommand
|
|
exports.deleteItemCommand = deleteItemCommand
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
|
|
|
// Shaped like a real `bw list items` entry, which carries the complete cipher
|
|
// -- this is what makes the second CLI call unnecessary.
|
|
const login = {
|
|
object: "item", id: "11111111-1111-1111-1111-111111111111",
|
|
organizationId: null, folderId: "f1", type: 1, name: "GitHub",
|
|
notes: "recovery codes in the safe", favorite: true,
|
|
login: {
|
|
username: "octocat", password: "s3cr3t-p4ss", totp: "JBSWY3DPEHPK3PXP",
|
|
uris: [{ match: null, uri: "https://github.com/login" }]
|
|
},
|
|
fields: [{ name: "recovery", value: "abcd-efgh", type: 1 }]
|
|
}
|
|
|
|
const card = {
|
|
object: "item", id: "22222222-2222-2222-2222-222222222222",
|
|
type: 3, name: "Visa", notes: "", favorite: false,
|
|
card: { cardholderName: "A Person", brand: "Visa", number: "4111111111111111",
|
|
expMonth: "04", expYear: "2030", code: "123" }
|
|
}
|
|
|
|
const identity = {
|
|
object: "item", id: "33333333-3333-3333-3333-333333333333",
|
|
type: 4, name: "Home", notes: "", favorite: false,
|
|
identity: { title: "Mr", firstName: "A", middleName: "Q", lastName: "Person",
|
|
username: "aperson", company: "Acme", email: "a@example.com",
|
|
phone: "555", ssn: "000-00-0000", passportNumber: "P123",
|
|
licenseNumber: "L456", address1: "1 Road", address2: "Flat 2",
|
|
address3: "", city: "Town", state: "ST",
|
|
postalCode: "00000", country: "US" }
|
|
}
|
|
|
|
const sshPublic = { id: "ssh-1", name: "Work SSH", type: 5, organizationId: "org-1",
|
|
folderId: "folder-1", favorite: true, reprompt: 1,
|
|
sshKey: { publicKey: "ssh-ed25519 AAAATEST", fingerprint: "SHA256:public" } }
|
|
const sanitized = Model.parseSanitizedItems(JSON.stringify({ items: [login], sshKeys: [sshPublic] }))
|
|
check("sanitized envelope adds a public SSH item", sanitized.length === 2
|
|
&& sanitized.some(i => i.typeCode === 5 && i.publicKey === "ssh-ed25519 AAAATEST"), JSON.stringify(sanitized))
|
|
const ssh = sanitized.find(i => i.typeCode === 5)
|
|
check("SSH search and favorite filtering use the combined list",
|
|
Model.filterItems(sanitized, "AAAATEST", "all", "all", "all").length === 1
|
|
&& Model.filterItems(sanitized, "", "favorite", "all", "all").some(i => i.id === "ssh-1"), JSON.stringify(sanitized))
|
|
check("SSH detail is public-only", ssh && Model.itemDetailFromObject(ssh.rawObject).password === ""
|
|
&& Model.itemDetailFromObject(ssh.rawObject).publicKey === "ssh-ed25519 AAAATEST", JSON.stringify(ssh))
|
|
check("generic write and private-read commands reject SSH", Model.buildCreatePayload(5, "x") === null
|
|
&& Model.buildEditPayload(ssh, "x") === null && Model.getItemCommand("ssh-1", 5).length === 0
|
|
&& Model.editItemCommand("ssh-1", 5).length === 0 && Model.deleteItemCommand("ssh-1", 5).length === 0, "guard missing")
|
|
|
|
// --- the equivalence the optimisation rests on ------------------------------
|
|
|
|
for (const raw of [login, card, identity]) {
|
|
const viaGetItem = Model.parseItemDetail(JSON.stringify(raw))
|
|
const viaList = Model.itemDetailFromObject(raw)
|
|
check(`${raw.name}: the list-built detail matches the get-item-built detail`,
|
|
JSON.stringify(viaList) === JSON.stringify(viaGetItem),
|
|
`\n list: ${JSON.stringify(viaList)}\n get: ${JSON.stringify(viaGetItem)}`)
|
|
}
|
|
|
|
// --- parseItems keeps what the detail view needs ----------------------------
|
|
|
|
const listed = Model.parseItems(JSON.stringify([login, card, identity]))
|
|
check("every listed item carries its raw object", listed.every(i => i.rawObject), "missing rawObject")
|
|
|
|
const listedLogin = listed.find(i => i.id === login.id)
|
|
const detail = Model.itemDetailFromObject(listedLogin.rawObject)
|
|
check("the password survives the round trip through the list",
|
|
detail.password === "s3cr3t-p4ss", detail.password)
|
|
check("so does the TOTP key", detail.totpKey === "JBSWY3DPEHPK3PXP", detail.totpKey)
|
|
check("so do custom fields, which the list view itself never shows",
|
|
detail.fields.length === 1 && detail.fields[0].name === "recovery"
|
|
&& detail.fields[0].value === "abcd-efgh", JSON.stringify(detail.fields))
|
|
check("so do notes", detail.notes === "recovery codes in the safe", detail.notes)
|
|
check("so do URIs", detail.uris[0] === "https://github.com/login", JSON.stringify(detail.uris))
|
|
|
|
const listedCard = listed.find(i => i.id === card.id)
|
|
const cardDetail = Model.itemDetailFromObject(listedCard.rawObject)
|
|
check("card numbers and codes survive too",
|
|
cardDetail.card.number === "4111111111111111" && cardDetail.card.code === "123",
|
|
JSON.stringify(cardDetail.card))
|
|
|
|
const listedIdentity = listed.find(i => i.id === identity.id)
|
|
const identityDetail = Model.itemDetailFromObject(listedIdentity.rawObject)
|
|
check("identity fields survive too",
|
|
identityDetail.identity.email === "a@example.com" && identityDetail.identity.postalCode === "00000",
|
|
JSON.stringify(identityDetail.identity))
|
|
|
|
// --- cards and identities are first-class, not decoration --------------------
|
|
//
|
|
// The model parsed both of these long before anything drew them, so these
|
|
// assertions guard the half that was always right as much as the half that
|
|
// was added: what the list shows, what search can find, and above all what
|
|
// survives an edit.
|
|
|
|
check("an identity carries the fields Bitwarden actually returns",
|
|
identityDetail.identity.middleName === "Q" && identityDetail.identity.company === "Acme"
|
|
&& identityDetail.identity.passportNumber === "P123"
|
|
&& identityDetail.identity.address2 === "Flat 2",
|
|
JSON.stringify(identityDetail.identity))
|
|
|
|
check("a full name closes the gaps rather than padding them",
|
|
Model.identityFullName({ title: "", firstName: "", middleName: "", lastName: "Person" }) === "Person",
|
|
JSON.stringify(Model.identityFullName({ lastName: "Person" })))
|
|
|
|
check("a card row is subtitled with its brand and last four",
|
|
listedCard.subtitle === "Visa •••• 1111", listedCard.subtitle)
|
|
check("an identity row is subtitled with its name, not left blank",
|
|
listedIdentity.subtitle === "Mr A Q Person", listedIdentity.subtitle)
|
|
|
|
// Anything the list is willing to show, the search box has to be able to find.
|
|
check("a card is found by its brand", Model.matchesQuery(listedCard, "visa"), listedCard.subtitle)
|
|
check("a card is found by its last four", Model.matchesQuery(listedCard, "1111"), listedCard.subtitle)
|
|
check("a card is not found by the middle of its number",
|
|
!Model.matchesQuery(listedCard, "111111111"), "a stored-card-number lookup is not this box's job")
|
|
check("an identity is found by name", Model.matchesQuery(listedIdentity, "person"), listedIdentity.subtitle)
|
|
check("an identity is found by email", Model.matchesQuery(listedIdentity, "a@example.com"), listedIdentity.subtitle)
|
|
|
|
// --- payloads ---------------------------------------------------------------
|
|
|
|
const createdCard = Model.buildCreatePayload(3, "New", "", "", "", "", "", false, null, null, null,
|
|
{ cardholderName: "B Person", brand: "MC", number: "5555444433332222", expMonth: "01", expYear: "2031", code: "999" })
|
|
check("creating a card emits a card object and no login",
|
|
createdCard.type === 3 && createdCard.card.number === "5555444433332222"
|
|
&& createdCard.card.code === "999" && createdCard.login === undefined,
|
|
JSON.stringify(createdCard))
|
|
|
|
const createdIdentity = Model.buildCreatePayload(4, "New", "", "", "", "", "", false, null, null, null,
|
|
{ firstName: "Ada", lastName: "Lovelace", email: "ada@example.com" })
|
|
check("creating an identity emits an identity object",
|
|
createdIdentity.type === 4 && createdIdentity.identity.firstName === "Ada"
|
|
&& createdIdentity.identity.email === "ada@example.com"
|
|
&& createdIdentity.identity.ssn === "",
|
|
JSON.stringify(createdIdentity))
|
|
|
|
// The regression that matters most here. The form can rename a card without
|
|
// ever showing its number, and the writers set every key they know -- so an
|
|
// edit that passes no type fields must leave the sub-object entirely alone,
|
|
// not blank it. This is what the `&& typeFields` guard in buildEditPayload is
|
|
// for, and it is worth an assertion because nothing about the call site looks
|
|
// dangerous.
|
|
const renamedCard = Model.buildEditPayload({ typeCode: 3, rawObject: card },
|
|
"Renamed", "", "", "", "", "", false, null, null, null)
|
|
check("renaming a card leaves its number, expiry and code untouched",
|
|
renamedCard.name === "Renamed" && renamedCard.card.number === "4111111111111111"
|
|
&& renamedCard.card.code === "123" && renamedCard.card.expYear === "2030",
|
|
JSON.stringify(renamedCard.card))
|
|
|
|
const renamedIdentity = Model.buildEditPayload({ typeCode: 4, rawObject: identity },
|
|
"Renamed", "", "", "", "", "", false, null, null, null)
|
|
check("renaming an identity leaves its fields untouched",
|
|
renamedIdentity.identity.email === "a@example.com" && renamedIdentity.identity.ssn === "000-00-0000",
|
|
JSON.stringify(renamedIdentity.identity))
|
|
|
|
const editedCard = Model.buildEditPayload({ typeCode: 3, rawObject: card },
|
|
"Visa", "", "", "", "", "", false, null, null, null,
|
|
{ cardholderName: "A Person", brand: "Visa", number: "4111111111111112", expMonth: "05", expYear: "2031", code: "321" })
|
|
check("an edit that does carry card fields writes them",
|
|
editedCard.card.number === "4111111111111112" && editedCard.card.code === "321"
|
|
&& editedCard.card.expMonth === "05",
|
|
JSON.stringify(editedCard.card))
|
|
|
|
check("editing a card never turns it into a login",
|
|
editedCard.type === 3 && editedCard.login === undefined, JSON.stringify(Object.keys(editedCard)))
|
|
|
|
// --- the encoder swap --------------------------------------------------------
|
|
//
|
|
// `bw encode` base64-encodes stdin and does nothing else -- no vault, no
|
|
// session, no network. It cost a full Bitwarden CLI startup, measured at 2.7
|
|
// seconds, on every save, folder creation and Send. coreutils does it in about
|
|
// two milliseconds. These assertions hold the two halves of that swap: the
|
|
// output really is identical, and the payload still never reaches argv.
|
|
|
|
const { execFileSync } = require("child_process")
|
|
const encodeSamples = [
|
|
'{"name":"Test","type":3}',
|
|
'{"name":"unicode \u00e9\u00e5\u4e2d","notes":"line1\nline2"}',
|
|
'{"name":"' + "x".repeat(500) + '"}',
|
|
'{"password":"p@ss w/ spaces & $pecial \'quotes\'"}',
|
|
]
|
|
for (const sample of encodeSamples) {
|
|
const ours = execFileSync("bash", ["-c", 'printf "%s" "$P" | base64 -w0'],
|
|
{ env: { ...process.env, P: sample } }).toString()
|
|
const node = Buffer.from(sample, "utf8").toString("base64")
|
|
check(`base64 -w0 matches a reference encoder for ${sample.slice(0, 28)}...`,
|
|
ours === node, `${ours}\n !=\n ${node}`)
|
|
}
|
|
|
|
check("base64 -w0 emits a single line, as the CLI's stdin requires",
|
|
!execFileSync("bash", ["-c", 'printf "%s" "$P" | base64 -w0'],
|
|
{ env: { ...process.env, P: '{"name":"' + "y".repeat(400) + '"}' } }).toString().includes("\n"),
|
|
"a wrapped encoding would reach `bw` as several lines")
|
|
|
|
for (const [label, cmd] of [
|
|
["create item", Model.createItemCommand({ name: "x" })[2]],
|
|
["edit item", Model.editItemCommand("id-1", 1)[2]],
|
|
]) {
|
|
check(`${label} pipes the payload from the environment through base64`,
|
|
/printf '%s' "\$QSBW_ITEM" \| base64 -w0 \|/.test(cmd), cmd)
|
|
check(`${label} still keeps the payload out of argv`,
|
|
!cmd.includes("password") && !cmd.includes("cardholderName"), cmd)
|
|
}
|
|
|
|
// --- splicing a save into the list ------------------------------------------
|
|
//
|
|
// A save used to be followed by re-listing and re-decrypting the whole vault
|
|
// to learn about the one item just written. The save's own response is the
|
|
// authoritative post-save state, so the list is brought up to date from that.
|
|
// These assertions cover the ways that can go wrong, because a list that
|
|
// quietly disagrees with the vault is worse than a slow one.
|
|
|
|
const envelope = (...objs) => JSON.stringify({
|
|
sshCapability: "unconfirmed", items: objs, sshKeys: []
|
|
})
|
|
|
|
const listed3 = Model.parseItems(JSON.stringify([login, card, identity]))
|
|
|
|
// An edit replaces in place and does not duplicate.
|
|
const renamed = { ...card, name: "Amex" }
|
|
const afterEdit = Model.spliceSavedItem(listed3, envelope(renamed))
|
|
check("editing an item replaces it rather than adding a second copy",
|
|
afterEdit.length === listed3.length
|
|
&& afterEdit.filter(i => i.id === card.id).length === 1,
|
|
JSON.stringify(afterEdit.map(i => i.name)))
|
|
check("the replacement carries the saved values",
|
|
afterEdit.find(i => i.id === card.id).name === "Amex",
|
|
JSON.stringify(afterEdit.find(i => i.id === card.id)))
|
|
|
|
// A create appends and sorts, rather than landing at the end of the list.
|
|
const created = { object: "item", id: "44444444-4444-4444-4444-444444444444",
|
|
type: 1, name: "AAA First", favorite: false, login: { username: "a" } }
|
|
const afterCreate = Model.spliceSavedItem(listed3, envelope(created))
|
|
check("creating an item adds it", afterCreate.length === listed3.length + 1,
|
|
String(afterCreate.length))
|
|
// The login fixture is a favourite, so it sorts above everything; the new
|
|
// item is expected at the head of the non-favourites, not of the whole list.
|
|
check("a created item lands in sort order, not at the end",
|
|
afterCreate.filter(i => !i.favorite)[0].name === "AAA First",
|
|
JSON.stringify(afterCreate.map(i => `${i.name}:${i.favorite}`)))
|
|
|
|
// Favourites sort above everything, so toggling one has to move the row.
|
|
const favourited = { ...card, favorite: true }
|
|
const afterFav = Model.spliceSavedItem(listed3, envelope(favourited))
|
|
check("favouriting an item moves it into the favourites block",
|
|
afterFav.filter(i => i.favorite).some(i => i.id === card.id)
|
|
&& afterFav.findIndex(i => i.id === card.id) < afterFav.findIndex(i => !i.favorite),
|
|
JSON.stringify(afterFav.map(i => `${i.name}:${i.favorite}`)))
|
|
|
|
// A rename has to re-sort too, or the row stays where its old name put it.
|
|
const renamedFirst = { ...login, name: "AAA Renamed" }
|
|
const afterRename = Model.spliceSavedItem(listed3, envelope(renamedFirst))
|
|
check("renaming an item re-sorts it",
|
|
afterRename.filter(i => i.favorite)[0].name === "AAA Renamed",
|
|
JSON.stringify(afterRename.map(i => `${i.name}:${i.favorite}`)))
|
|
|
|
// The spliced row must be a list row, not a raw cipher: the list draws
|
|
// subtitles and copy buttons off these fields.
|
|
const splicedCard = afterEdit.find(i => i.id === card.id)
|
|
check("a spliced row is parsed into list shape, not left as a raw cipher",
|
|
splicedCard.typeCode === 3 && splicedCard.subtitle === "Visa •••• 1111"
|
|
&& splicedCard.hasPassword === false,
|
|
JSON.stringify(splicedCard))
|
|
|
|
// --- everything that must fall back to a full reload -------------------------
|
|
|
|
check("an unrecognised envelope refuses to splice",
|
|
Model.spliceSavedItem(listed3, '{"not":"an envelope"}') === null, "expected null")
|
|
check("malformed JSON refuses to splice",
|
|
Model.spliceSavedItem(listed3, "{oops") === null, "expected null")
|
|
check("an envelope carrying more than one item refuses to splice",
|
|
Model.spliceSavedItem(listed3, envelope(renamed, created)) === null, "expected null")
|
|
check("an empty envelope refuses to splice",
|
|
Model.spliceSavedItem(listed3, envelope()) === null, "expected null")
|
|
check("an item with no id refuses to splice",
|
|
Model.spliceSavedItem(listed3, envelope({ ...card, id: "" })) === null, "expected null")
|
|
|
|
check("the saved-but-unsanitized marker is a fixed sentinel the panel can test",
|
|
typeof Model.savedUnsanitizedMarker() === "string"
|
|
&& Model.savedUnsanitizedMarker().length > 0
|
|
&& Model.spliceSavedItem(listed3, Model.savedUnsanitizedMarker()) === null,
|
|
Model.savedUnsanitizedMarker())
|
|
|
|
// The save pipeline must sanitize its response the same way the list does,
|
|
// because a save returns a complete decrypted cipher just as `bw list` does.
|
|
const createCmd = Model.createItemCommand({ name: "x" })[2]
|
|
check("a save runs its response through the strict JSON validator",
|
|
createCmd.includes("TextDecoder") && createCmd.includes("Array.isArray"), createCmd.slice(0, 200))
|
|
check("a save runs its response through the allowlisting filter",
|
|
createCmd.includes("ordinary item carries an SSH key subtree")
|
|
&& createCmd.includes("sshCapability"), createCmd.slice(0, 200))
|
|
check("a failed save is never reported as a success",
|
|
/if \[ "\$__rc" -ne 0 \]; then exit "\$__rc"; fi/.test(createCmd), createCmd)
|
|
|
|
// --- saving without making the user wait -------------------------------------
|
|
//
|
|
// A save costs whatever `bw` costs: a second or two of CLI startup, vault
|
|
// decryption and a round trip, none of which this plugin can shorten. So the
|
|
// form closes when the command is launched and the list shows the item as it
|
|
// will be, marked as saving, until the vault answers.
|
|
|
|
const draftCard = { type: 3, name: "Draft Visa", notes: "", favorite: false,
|
|
card: { brand: "Visa", number: "4111111111111111", code: "999",
|
|
expMonth: "01", expYear: "2031", cardholderName: "A Person" } }
|
|
|
|
const provisional = Model.pendingItemId(12345)
|
|
const optimistic = Model.optimisticItem(draftCard, provisional)
|
|
|
|
check("an optimistic row is built through the same parser as a real one",
|
|
optimistic.typeCode === 3 && optimistic.subtitle === "Visa •••• 1111"
|
|
&& optimistic.hasPassword === false,
|
|
JSON.stringify(optimistic))
|
|
check("and is marked as still saving", optimistic.pending === true, JSON.stringify(optimistic))
|
|
check("a provisional id is recognisable as one",
|
|
Model.isPendingItemId(optimistic.id), optimistic.id)
|
|
check("a real vault id is not mistaken for a provisional one",
|
|
!Model.isPendingItemId(card.id), card.id)
|
|
|
|
// The response carries the id the server assigned, which is not the one the
|
|
// row went in under.
|
|
const createdEnvelope = JSON.stringify({
|
|
sshCapability: "unconfirmed",
|
|
items: [{ ...draftCard, object: "item", id: "55555555-5555-5555-5555-555555555555" }],
|
|
sshKeys: []
|
|
})
|
|
const withOptimistic = Model.replaceItemById(listed3, provisional, optimistic)
|
|
check("the optimistic row goes into the list", withOptimistic.length === listed3.length + 1,
|
|
String(withOptimistic.length))
|
|
|
|
const settled = Model.spliceSavedItem(withOptimistic, createdEnvelope, provisional)
|
|
check("the saved item replaces the provisional row rather than joining it",
|
|
settled.length === withOptimistic.length
|
|
&& settled.filter(i => Model.isPendingItemId(i.id)).length === 0,
|
|
JSON.stringify(settled.map(i => i.id)))
|
|
check("and lands under the id the server assigned",
|
|
settled.some(i => i.id === "55555555-5555-5555-5555-555555555555"),
|
|
JSON.stringify(settled.map(i => i.id)))
|
|
check("the settled row is no longer marked as saving",
|
|
!settled.find(i => i.id === "55555555-5555-5555-5555-555555555555").pending,
|
|
"a row that has landed must not keep spinning")
|
|
|
|
// A refused save must not leave the panel showing something the vault rejected.
|
|
check("a failed create takes its provisional row back out",
|
|
Model.replaceItemById(withOptimistic, provisional, null).length === listed3.length,
|
|
"a create that failed must leave no row behind")
|
|
|
|
const editOptimistic = Model.optimisticItem(
|
|
{ ...card, name: "Renamed while saving" }, card.id)
|
|
const duringEdit = Model.replaceItemById(listed3, card.id, editOptimistic)
|
|
check("an optimistic edit replaces in place rather than duplicating",
|
|
duringEdit.length === listed3.length, String(duringEdit.length))
|
|
check("a failed edit puts the previous row back",
|
|
(() => {
|
|
const before = Model.findItemById(listed3, card.id)
|
|
const after = Model.replaceItemById(duringEdit, card.id, before)
|
|
const restored = Model.findItemById(after, card.id)
|
|
return after.length === listed3.length && restored.name === "Visa" && !restored.pending
|
|
})(), "the list must return to what the vault actually holds")
|
|
|
|
check("finding an item by id returns null rather than throwing when absent",
|
|
Model.findItemById(listed3, "nope") === null, "expected null")
|
|
|
|
// --- the fallback path still has to behave ----------------------------------
|
|
|
|
check("a missing raw object yields null rather than a broken detail",
|
|
Model.itemDetailFromObject(null) === null, String(Model.itemDetailFromObject(null)))
|
|
check("so does a non-object", Model.itemDetailFromObject("nope") === null,
|
|
String(Model.itemDetailFromObject("nope")))
|
|
check("unparseable JSON still yields null from the string form",
|
|
Model.parseItemDetail("{not json") === null, String(Model.parseItemDetail("{not json")))
|
|
|
|
// --- the type glyphs -------------------------------------------------------
|
|
//
|
|
// Pinned by codepoint, because a wrong one is invisible in review: the glyph
|
|
// renders as a small picture in the editor and the name is nowhere in the
|
|
// source. Two of these were wrong for exactly that reason -- Secure Note drew
|
|
// md-fan (a ceiling fan) and Card drew md-close_octagon_outline (a stop sign),
|
|
// both under comments claiming otherwise. The values below are the same ones
|
|
// the type filter chips in Panel.qml use, which is the point: a row and the
|
|
// chip that selects it should not disagree.
|
|
|
|
const glyphs = [
|
|
[1, 0xF030B, "md-key_variant", "Login"],
|
|
[2, 0xF0219, "md-file_document", "Secure Note"],
|
|
[3, 0xF0FEF, "md-credit_card", "Card"],
|
|
[4, 0x0F007, "fa-user", "Identity"]
|
|
]
|
|
for (const [typeCode, cp, name, label] of glyphs) {
|
|
const got = Model.itemTypeGlyph(typeCode)
|
|
check(`${label} draws ${name}`, got.codePointAt(0) === cp,
|
|
`U+${got.codePointAt(0).toString(16).toUpperCase()}`)
|
|
check(`${label} is one glyph, not a sequence`, [...got].length === 1, JSON.stringify(got))
|
|
}
|
|
// itemTypeName() already answers "login" for anything it does not recognise,
|
|
// so a cipher type Bitwarden adds later renders as a login rather than as
|
|
// nothing. That also means itemTypeGlyph's own `default:` shield can never be
|
|
// reached -- pinned here so the next reader does not go looking for it.
|
|
check("an unrecognised type is drawn as a login, not as the unreachable shield",
|
|
Model.itemTypeGlyph(99).codePointAt(0) === 0xF030B,
|
|
Model.itemTypeGlyph(99).codePointAt(0).toString(16))
|
|
|
|
// A key icon on the password controls, not a refresh icon. Pinned by button
|
|
// rather than by count, because what broke this was a bulk glyph replacement
|
|
// that meant to touch one new button and silently rewrote every other use of
|
|
// the same codepoint. A count alone would have moved with it.
|
|
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
|
const KEY = String.fromCodePoint(0xF0306)
|
|
const passwordButtons = [
|
|
['tooltipText: "Password generator (g)"', "the generator button"],
|
|
['tooltipText: "Copy password (Enter / y)"', "copy password on an item row"],
|
|
['tooltipText: "Copy password (y / Enter)"', "copy password in the detail view"],
|
|
['selected: root.genOpts.type === "password"', "the generator's Password type"],
|
|
]
|
|
for (const [anchor, label] of passwordButtons) {
|
|
const at = panelSrc.indexOf(anchor)
|
|
const before = at < 0 ? "" : panelSrc.slice(Math.max(0, at - 200), at)
|
|
const icon = before.lastIndexOf("iconText:")
|
|
check(`${label} wears the key glyph`,
|
|
at >= 0 && icon >= 0 && before.slice(icon).includes(KEY),
|
|
at < 0 ? `anchor missing: ${anchor}` : JSON.stringify(before.slice(icon).trim()))
|
|
}
|
|
check("the Generate... button wears it too",
|
|
/text: "Generate\.\.\."[\s\S]{0,80}iconText: "\u{F0306}"/u.test(panelSrc),
|
|
"the field-level generator shortcut")
|
|
|
|
console.log(`${pass} passed, ${failures.length} failed`)
|
|
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|