Home›Lendra›Test run
Lendra · Test run · run 2026-11-07

Lendra Test run, as shipped in the download

499 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 with php tests/run.php. Run it yourself after you download.

[DB: SQLite]

== 1. dueDate: loan-days → due date ==
  PASS  7 days (got '2026-01-08', want '2026-01-08')
  PASS  0 days = same day (got '2026-01-01', want '2026-01-01')
  PASS  month rollover (got '2026-03-04', want '2026-03-04')
  PASS  year rollover (got '2027-01-04', want '2027-01-04')
  PASS  leap-year Feb (got '2024-03-01', want '2024-03-01')
  PASS  datetime input trimmed (got '2026-01-08', want '2026-01-08')
  PASS  negative days clamped (got '2026-01-01', want '2026-01-01')

== 2. endOfDayUtc: local due date → last second UTC ==
  PASS  UTC same day (got '2026-01-08 23:59:59', want '2026-01-08 23:59:59')
  PASS  EST (UTC-5) (got '2026-01-09 04:59:59', want '2026-01-09 04:59:59')
  PASS  EDT (UTC-4, summer) (got '2026-07-09 03:59:59', want '2026-07-09 03:59:59')
  PASS  datetime input trimmed (got '2026-01-08 23:59:59', want '2026-01-08 23:59:59')

== 3. isOverdue: strict due boundary, returned never overdue ==
  PASS  one second past due -> overdue
  PASS  exactly at due -> NOT overdue
  PASS  before due -> not overdue
  PASS  returned item never overdue
  PASS  null due -> not overdue
  PASS  empty due -> not overdue

== 4. daysOverdue ==
  PASS  exactly 3 days late (got 3, want 3)
  PASS  not overdue -> 0 (got 0, want 0)
  PASS  returned -> 0 (got 0, want 0)
  PASS  partial day -> 0 (got 0, want 0)

== 5. loanState ==
  PASS  returned (got 'returned', want 'returned')
  PASS  overdue (got 'overdue', want 'overdue')
  PASS  out (got 'out', want 'out')

== 6. toCsv: RFC-4180 escaping ==
  PASS  header row (got 'Asset,Asset Tag,Category,Borrower,Department,Checked Out,Due,Returned,State,Condition Out,Condition In,Notes', want 'Asset,Asset Tag,Category,Borrower,Department,Checked Out,Due,Returned,State,Condition Out,Condition In,Notes')
  PASS  quotes/commas/newlines escaped
  PASS  CRLF line endings
  PASS  first cell = asset (got 'Drill', want 'Drill')
  PASS  tag cell (got 'TOOL-042', want 'TOOL-042')
  PASS  missing category blank (got '', want '')
  PASS  row 2 borrower blank (got '', want '')
  PASS  formula = neutralized (got '\'=1+2', want '\'=1+2')
  PASS  formula + neutralized (got '\'+HYPERLINK("x")', want '\'+HYPERLINK("x")')
  PASS  - number NOT quoted (got '-5', want '-5')
  PASS  formula @ neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  leading TAB neutralized (got '\'	Tabbed', want '\'	Tabbed')
  PASS  safe cell untouched (got 'Safe', want 'Safe')
  PASS  normal value untouched (got 'Jordan Rivera', want 'Jordan Rivera')

== 7. Check-out state machine ==
  PASS  check-out of available asset succeeds
  PASS  returns a checkout id
  PASS  asset now checked_out (got 'checked_out', want 'checked_out')
  PASS  checkout row is open (returned_at NULL)
  PASS  checked_out_at recorded (got '2026-03-01 10:00:00', want '2026-03-01 10:00:00')
  PASS  due stored as end-of-day UTC (got '2026-03-08 23:59:59', want '2026-03-08 23:59:59')
  PASS  condition_out stored (got 'good', want 'good')
  PASS  double check-out blocked
  PASS  double check-out reason mentions checked out
  PASS  due date in the past refused
  PASS  past-date reason says so
  PASS  due today is allowed
  PASS  cannot check out maintenance asset
  PASS  maintenance reason surfaced
  PASS  cannot check out retired asset
  PASS  retired reason surfaced
  PASS  inactive borrower blocked
  PASS  asset stays available after failed check-out
  PASS  non-existent asset blocked
  PASS  non-existent borrower blocked
  PASS  invalid due date blocked
  PASS  asset still available after all failed attempts

== 8. Check-in state machine ==
  PASS  check-in of open loan succeeds
  PASS  returned_at recorded (got '2026-03-05 14:00:00', want '2026-03-05 14:00:00')
  PASS  condition_in recorded (got 'fair', want 'fair')
  PASS  return note appended to notes
  PASS  asset back to available (got 'available', want 'available')
  PASS  double check-in blocked
  PASS  check-in of unknown loan blocked
  PASS  returned asset can be checked out again
  PASS  asset checked_out again (got 'checked_out', want 'checked_out')

== 9. Asset status transitions ==
  PASS  available -> maintenance ok
  PASS  status is maintenance (got 'maintenance', want 'maintenance')
  PASS  maintenance -> available ok
  PASS  cannot set status to checked_out directly
  PASS  invalid status rejected
  PASS  checked-out asset cannot change status
  PASS  retired asset is not checkoutable

== 10. Overdue detection & borrower-holding queries ==
  PASS  two open loans created
  PASS  overdue query includes the past-due loan
  PASS  overdue query excludes the on-time loan
  PASS  open loans include both current loans
  PASS  borrower-holding lists their open loans
  PASS  borrower-holding excludes returned loans
  PASS  returned loan leaves the overdue list
  PASS  overdue asset returned to available (got 'available', want 'available')

== 11. Oversize-input guards (MySQL-strict safety) ==
  PASS  str_cap trims to width (got 190, want 190)
  PASS  str_cap leaves short strings (got 'Dell Laptop', want 'Dell Laptop')
  PASS  str_cap is multibyte-safe (got 120, want 120)
  PASS  clamp_loan_days caps INT overflow (got 3650, want 3650)
  PASS  clamp_loan_days floors at 1 (got 1, want 1)
  PASS  clamp_loan_days floors negatives (got 1, want 1)
  PASS  clamp_loan_days keeps sane values (got 14, want 14)
  PASS  capped oversize asset name inserts without error
  PASS  stored capped name is exactly the column width (got 190, want 190)
  PASS  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause

----------------------------------------------------

== PWA ==
  PASS  manifest.json is valid JSON
  PASS  manifest has required fields
  PASS  manifest URLs are RELATIVE (subpath-install safe)
  PASS  manifest icons exist on disk (192 + 512 + apple-touch)
  PASS  sw.js never caches non-GET
  PASS  sw.js never caches page navigations (auth HTML can not enter cache storage)
  PASS  sw.js cache name carries the version (old caches purged on activate)
  PASS  offline fallback page ships
  PASS  every app layout links the manifest + registers the SW

-- Own It 2.0 --
  PASS  v2: tables
  PASS  v2: totp_cols
  PASS  v2: smtp_cols
  PASS  v2: adopts_legacy_email
  PASS  v2: never_overwrites_notify_email
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: csv_commit
  PASS  v2: csv_dup_skips
  PASS  v2: checkout_ok
  PASS  v2: guard_double_issue
  PASS  v2: guard_inactive_borrower
  PASS  v2: guard_past_due
  PASS  v2: overdue_computed
  PASS  v2: checkin_once
  PASS  v2: loan_serializer
  PASS  v2: asset_serializer
  PASS  v2: backup_tables
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on every boot (1.x had none — existing installs need it)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: notifications + webhooks fire on the BROWSER path too, never fatally
  PASS  events: item.checked_out and item.returned are fired from one function each
  PASS  events: the counter, the REST API and the agent all call the shared issue side effects
  PASS  events: the check-in page, the REST API, the agent and the desk all call the shared return side effects
  PASS  v2: API reuses the tested core (no re-derived guards)
  PASS  v2: the API never flips asset status itself (double-issue is Checkout::checkOut's job)
  PASS  v2: dark palette + no hardcoded white backgrounds outside @media print
  PASS  v2: theme.js + qr.js ship and layout loads theme.js + a toggle
  PASS  v2: shared modules are copy-identical to the shared core (never forked per app)
  PASS  API.md ships + the sw cache name carries a version

== Own It 3.0: MCP · roles · audit · reservations · PDF · iCal · GDPR ==
  PASS  desk: an available item reads as ready to issue
  PASS  desk: an item that is out is refused, naming who has it
  PASS  desk: the verdict agrees with a real checkOut() attempt
  PASS  desk: one-click return runs the shared check-in
  PASS  desk: returning the same loan twice is refused
  PASS  desk: a held item is refused per borrower, not globally
  PASS  desk: return.json is role-gated, CSRF-checked and delegates
  PASS  desk: lookup.json is read-only and permission-gated
  PASS  overdue: cadence column + the once-per-borrower ledger exist
  PASS  overdue: a fresh install writes to no borrower
  PASS  overdue: on, but with no SMTP server, still sends nothing
  PASS  overdue: no address and anonymized people are never written to
  PASS  overdue: a refused send is not counted and leaves no mark
  PASS  overdue: ONE mail per borrower, listing all their late items
  PASS  overdue: the notice names the item, the tag and the days late
  PASS  overdue: a second run the same day writes to nobody
  PASS  overdue: the cadence holds a borrower off until it passes
  PASS  overdue: the unique index really refuses a duplicate
  PASS  overdue: the button is role-gated, CSRF-checked and delegates
  PASS  overdue: the token URL refuses a bad token and delegates
  PASS  overdue: cron/overdue.php owns no rule of its own
  PASS  overdue: Settings now shows the switches that write to a borrower
  PASS  overdue: Settings no longer denies a feature the product ships
  PASS  settings: saving the org form no longer erases the SMTP config
  PASS  settings: each form declares its block and only that block is written
  PASS  settings: the input that fed nothing is gone, its value preserved
  PASS  overdue: saving Settings persists those switches
  PASS  migrate() creates audit_log + reservations + asset_photos + invites
  PASS  role / accent / ical token / claim-window columns exist
  PASS  users.active exists (the suspend switch the Team page reads)
  PASS  the Team page's own query runs instead of fatalling
  PASS  the last-admin count query runs
  PASS  a switched-off person's API key resolves to nothing
  PASS  migrate() creates the settings row (only the installer used to)
  PASS  UPGRADE PATH: drop every v3 table, migrate, they come back
  PASS  member runs the desk but cannot retire assets or erase borrowers
  PASS  viewer reads and cannot issue
  PASS  viewer canNOT read the audit trail or team roster
  PASS  admin can retire and erase
  PASS  the last admin cannot be demoted
  PASS  a clean issue succeeds
  PASS  THE GUARD: a second issue of the same asset is refused
  PASS  reserving an out asset joins the queue as waiting
  PASS  a borrower cannot take two places in one queue
  PASS  on return, the head of the queue is offered it with a claim window
  PASS  the offer goes to the FIRST person who joined, not the newest
  PASS  a promised asset cannot be issued to a walk-up (refusal names the holder)
  PASS  the holder CAN take it, and that fulfills their reservation
  PASS  returning again promotes the next person
  PASS  an expired claim window passes the offer on — no cron needed
  PASS  once the queue drains the asset is simply free
  PASS  an ordinary issue works again afterward
  PASS  canceling the holder passes the offer immediately
  PASS  a borrower holding an asset is detected (anonymize refuses)
  PASS  GDPR export returns the subject rows
  PASS  anonymize blanks name/email/phone/department
  PASS  loan history survives anonymization (the assets still need a record)
  PASS  MCP initialize reports lendra 4.1.3
  PASS  MCP exposes exactly the row's five tools, in order
  PASS  every tool description declares READ-ONLY or WRITES
  PASS  a nested argument that is not allowlisted is refused
  PASS  an unknown argument is refused by name
  PASS  GET /mcp is refused with 405
  PASS  issue_asset lends through Checkout::checkOut
  PASS  GUARDS-REUSE: MCP double-issue refused in the PRODUCT's own words
  PASS  the MCP layer never flips asset status itself (code, comments stripped)
  PASS  MCP role gate matches the UI: a viewer key cannot issue
  PASS  an MCP write lands in the audit trail
  PASS  return_asset reports who the item was offered to
  PASS  iCal token verifies and rejects tampering
  PASS  the feed is well-formed with stable UIDs and an exclusive all-day DTEND
  PASS  the calendar feed carries no email addresses
  PASS  the loan receipt renders as a real PDF
  PASS  inkOn() clears 4.5:1
  PASS  a PHP script named .png is refused
  PASS  another product's backup is refused by name stamp
  PASS  OIDC refuses alg=none

== Own It 3.0: wiring (static) ==
  PASS  Database::migrate() runs at boot
  PASS  src includes are idempotent, so load order cannot redeclare a class
  PASS  POST and GET /mcp are both routed (a method probe must see 405, not 404)
  PASS  bulk routes precede the {id} routes that would swallow them
  PASS  the MCP endpoint authenticates with the existing API key first
  PASS  issue_asset calls Checkout::checkOut and validates nothing itself
  PASS  the reservation guard is consulted INSIDE Checkout::checkOut, not beside it
  PASS  check-in promotes the queue head inside the same transaction
  PASS  anonymize refuses while the borrower still holds something
  PASS  the anonymize audit entry records counts, not the erased name
  PASS  the iCal feed is token-guarded and session-free
  PASS  the calendar feed is REACHABLE: settings offers the mint form
  PASS  the calendar URL is actually printed once, not just put in the session
  PASS  overdue notices are REACHABLE: the overdue list can send them
  PASS  the Team page describes LENDRA, not another product
  PASS  the assets list actually exposes bulk selection and the saved-view control
  PASS  bulk status reuses Checkout::setAssetStatus per row in one transaction
  PASS  every desk mutation is role-gated
  PASS  every desk mutation writes an audit entry
  PASS  button ink follows the branding contrast helper, not a hardcoded #fff
  PASS  the branding accent overrides BOTH themes, not just :root
  PASS  src/Mcp.php is byte-identical to the shared core copy of Mcp.php
  PASS  src/AuditLog.php is byte-identical to the shared core copy of AuditLog.php
  PASS  src/Pdf.php is byte-identical to the shared core copy of Pdf.php
  PASS  src/BackupRestore.php is byte-identical to the shared core copy of BackupRestore.php
  PASS  src/Branding.php is byte-identical to the shared core copy of Branding.php
  PASS  src/RolesKit.php is byte-identical to the shared core copy of RolesKit.php
  PASS  src/Oidc.php is byte-identical to the shared core copy of Oidc.php
  PASS  src/Gdpr.php is byte-identical to the shared core copy of Gdpr.php
  PASS  src/Ical.php is byte-identical to the shared core copy of Ical.php
  PASS  every /api-settings handler is role-gated, not merely logged-in
  PASS  creating or deleting a webhook is ADMIN-only (it is an off-box data feed)
  PASS  revoking an API key is ADMIN-only (it takes every integration down)
  PASS  the settings page itself is role-gated, not merely logged-in
  PASS  the nav never links a role to a page it will be refused
  PASS  every /api/* WRITE route role-checks the key, not just its existence
  PASS  the roles map keeps admin able to administer, and keeps settings.manage away from the rest
  PASS  an MCP tool refusal THROWS McpRefusal — never a result the agent reads as success
  PASS  wiping the register is admin-only, not merely logged-in
  PASS  sw.js cache is static-v4.1.3
  PASS  healthz + api_ping + openapi report 4.1.3
  PASS  API.md documents the MCP endpoint
  PASS  README documents "New in 3.0"
  PASS  the deploy kit is filled in

== 3.1.4: backups are admin-only · the scan desk runs on MySQL ==
  PASS  backup: a viewer is refused the JSON backup (403, no tables)
  PASS  backup: a viewer is refused the database file (403, no SQLite bytes)
  PASS  backup: the Security page shows a viewer no backup buttons
  PASS  backup: a member is refused the JSON backup (403, no tables)
  PASS  backup: a member is refused the database file (403, no SQLite bytes)
  PASS  backup: the Security page shows a member no backup buttons
  PASS  backup: an admin still downloads the JSON backup
  PASS  backup: an admin still downloads the database file
  PASS  backup: the Security page still offers an admin both downloads
  PASS  mysql: no SQL in the app uses the SQLite-only NOCASE collation
  PASS  scan: a tag typed in the wrong case still finds the asset exactly
  PASS  scan: the exact tag finds it and says it can go out
  PASS  scan: an unknown tag is no exact match (never a near miss)
  PASS  cron: cron/overdue.php refuses anything but the command line
  PASS  cron: the web server refuses /cron/ (router and .htaccess)

== 3.1.4: "due back soon" reminders · damaged returns go to maintenance ==
  PASS  due soon columns and ledger exist
  PASS  due soon is off on a fresh install
  PASS  due soon refuses without an smtp server
  PASS  due soon dry run names only the reachable borrower with items in window
  PASS  due soon a refused send is not counted and releases its claims
  PASS  due soon one message per borrower with every item due
  PASS  due soon a loan is reminded once per due date
  PASS  due soon window is configurable and an extended loan is reminded again
  PASS  due soon runs from cron and the token url
  PASS  damaged return goes to maintenance not the shelf
  PASS  damaged return is not offered to the queue
  PASS  a damaged item cannot be issued
  PASS  back to available offers it to the head of the queue
  PASS  a poor return still goes back on the shelf
  PASS  agent issue fires item checked out
  PASS  agent return fires item returned
  PASS  settings: the "due back soon" switch and its window are on the Email form

== fix wave: every backup and restore route is administrator-only ==
  PASS  backup: a signed-in viewer gets 403 on GET /backup.json
  PASS  backup: a signed-in viewer gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in viewer gets 403 on GET /restore
  PASS  backup: a signed-in viewer gets 403 on POST /restore/preview
  PASS  backup: a signed-in viewer gets 403 on POST /restore/commit
  PASS  backup: a signed-in viewer gets 403 on POST /backup/token
  PASS  backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
  PASS  backup: a signed-in member gets 403 on GET /backup.json
  PASS  backup: a signed-in member gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in member gets 403 on GET /restore
  PASS  backup: a signed-in member gets 403 on POST /restore/preview
  PASS  backup: a signed-in member gets 403 on POST /restore/commit
  PASS  backup: a signed-in member gets 403 on POST /backup/token
  PASS  backup: a signed-in member without the token gets nothing from GET /backup/scheduled
  PASS  backup: an admin still opens the restore page
  PASS  ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)

== fix wave: the webhook buttons work ==
  PASS  webhooks: Add creates the endpoint (no 404)
  PASS  webhooks: Pause switches it off (no 404)
  PASS  webhooks: pressing it again switches it back on
  PASS  webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
  PASS  webhooks: Delete removes it (no 404)

-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
  PASS  demo ribbon: Lendra has no public page, so a page served without sign-in keeps the admin wording too
  PASS  demo ribbon: an administrator's page keeps the admin wording
  PASS  demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
  PASS  demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
  PASS  backup: the JSON download carries none of the 12 credentials this schema holds (settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, settings.ldap_bind_pass, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, backup_state.s3_secret, invites.token_hash)
  PASS  backup: the scheduled backup writer blanks the same list as the download (one list, lendra_backup_redact())
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key

== 3.2.0 · asset labels: the barcode ==
  PASS  "LN-0042" = START B, the tag, checksum, STOP (got array (
  0 => 104,
  1 => 44,
  2 => 46,
  3 => 13,
  4 => 16,
  5 => 16,
  6 => 20,
  7 => 18,
  8 => 51,
  9 => 106,
), want array (
  0 => 104,
  1 => 44,
  2 => 46,
  3 => 13,
  4 => 16,
  5 => 16,
  6 => 20,
  7 => 18,
  8 => 51,
  9 => 106,
))
  PASS  a 41-character or accented tag is refused (the label says so instead of printing a wrong code)

== 3.2.0 · labels through the front controller ==
  PASS  Labels: a viewer can choose (the seeded LN-0042 is listed)
  PASS  Labels: signed out, nothing is shown
  PASS  Sheet: the tag as a Code 128 barcode, named for a screen reader
  PASS  Sheet: a QR code of this install's /scan/LN-0042 address
  PASS  Sheet: two labels left blank for a part-used sheet
  PASS  Sheet: barcode only when asked
  PASS  A label QR, signed in: redirected (to the asset)
  PASS  A QR for an unknown tag says so

== 3.2.0 · the QR lands on the asset, over HTTP (php -S) ==
  PASS  the app answers under php -S
  PASS  signed in: /scan/LN-0042 → the asset's own page (got array (
  0 => 302,
  1 => '/assets/1',
), want array (
  0 => 302,
  1 => '/assets/1',
))
  PASS  the tag matches whatever the case (got '/assets/1', want '/assets/1')
  PASS  signed out: → the login page (got array (
  0 => 302,
  1 => '/login',
), want array (
  0 => 302,
  1 => '/login',
))
  PASS  the QR carries this install's own address (http://kit.example.org/scan/LN-0042)

== 3.2.0 · the camera at the desk ==
  PASS  the desk has a camera button, a preview and the reader script
  PASS  a camera hit takes the keyboard scanner's path (window.lendraScanHit → lookup)
  PASS  the reader tries the browser's BarcodeDetector, then its own Code 128 reader, and never writes HTML from a scan
  PASS  the reader's table is the one the labels print from
  (node is not on this PATH: run `node tests/scan_decode.test.js` for the camera reader's own 151 checks)

== 3.2.0 pass B · signatures at hand-over and return ==
  PASS  off: whatever is posted is ignored
  PASS  optional (the default): no signature is fine
  PASS  optional: a signed pad is kept as its PNG
  PASS  a pad nobody signed is refused, not filed as a signature
  PASS  anything that is not a PNG from the pad is refused
  PASS  required: no signature, no hand-over (got 'A signature is needed: ask the borrower to sign on the pad.', want 'A signature is needed: ask the borrower to sign on the pad.')
  PASS  the desk shows the pad and loads its script
  PASS  a blank pad at the desk refuses the hand-over and lends nothing
  PASS  signed at hand-over: the loan carries the signature and when
  PASS  required: the one-click desk return points to the check-in page instead
  PASS  required: no return without a signature
  PASS  signed at return: returned, with the signature
  PASS  the loan record shows both signatures with what was signed
  PASS  the asset's history links the signed loan
  PASS  the receipt PDF says both were signed on the pad

== 3.2.0 pass B · the borrower's own page (self-service requests) ==
  PASS  a viewer cannot create a borrower link
  PASS  the desk creates a link; only its hash is kept
  PASS  emailing a link without a mail server is refused and changes nothing
  PASS  a wrong or revoked link opens nothing
  PASS  the page lists requestable items only (not the server key), with the drone out until its due date
  PASS  the page never shows who else has an item
  PASS  requests join the queues: the drone waits, the tripod on the shelf is ready at once; the server key cannot be requested
  PASS  the page shows "Ready for you" and "Next in line"
  PASS  asking twice for the same item adds nothing
  PASS  a borrower holds at most the set number of open requests
  PASS  a borrower cannot cancel somebody else's place
  PASS  a borrower cancels their own request, audited
  PASS  an inactive borrower's link stops working
  PASS  backups leave out the link

== 3.2.0 pass B · your own fields on every asset ==
  PASS  a choice field needs its options (got 'A choice field needs its options, separated by commas.', want 'A choice field needs its options, separated by commas.')
  PASS  built-in column names and duplicates are refused
  PASS  fields keep their order and a choice its unique options (got array (
  0 => 
  array (
    0 => 'Serial number',
    1 => 'Purchased',
    2 => 'Cost',
    3 => 'Kit',
    4 => 'Insured',
  ),
  1 => 
  array (
    0 => 'Basic',
    1 => 'Pro',
  ),
), want array (
  0 => 
  array (
    0 => 'Serial number',
    1 => 'Purchased',
    2 => 'Cost',
    3 => 'Kit',
    4 => 'Insured',
  ),
  1 => 
  array (
    0 => 'Basic',
    1 => 'Pro',
  ),
))
  PASS  values are checked by type: a date is a date, a number a number, a choice one of its options, yes/no
  PASS  defining fields is for administrators
  PASS  the asset form has the fields (date input, choice list)
  PASS  a bad value refuses the whole asset, with the reason
  PASS  saved with the asset: the serial, the date, the owner in the field's own spelling (got array (
  1 => 'SN-88-XQ',
  2 => '2026-12-31',
  3 => 'Media',
), want array (
  1 => 'SN-88-XQ',
  2 => '2026-12-31',
  3 => 'Media',
))
  PASS  the asset page lists them
  PASS  search finds an asset by a field value
  PASS  editing: an emptied field is removed, a changed one updated (got array (
  2 => '2027-01-15',
  3 => 'Media',
), want array (
  2 => '2027-01-15',
  3 => 'Media',
))
  PASS  the assets CSV has a column per field
  PASS  the import spec matches a column to a field by its name (the row with an unknown owner is skipped with the reason)
  PASS  the importer itself saves field values (source)
  PASS  removing a field removes its values
  PASS  the REST API and the agent get the fields by name (got '[{"Serial number":"XY-1","Insured":"yes"},{"Serial number":"XY-1","Insured":"yes"}]', want '[{"Serial number":"XY-1","Insured":"yes"},{"Serial number":"XY-1","Insured":"yes"}]')

== 3.2.0 pass B · sign in with the organization's directory (LDAP / Active Directory) ==
  PASS  the user filter escapes the name (a star or a bracket is a character, not a pattern) (got array (
  0 => '(sAMAccountName=ada)',
  1 => '(uid=a\\2a\\29\\28uid=\\2a)',
), want array (
  0 => '(sAMAccountName=ada)',
  1 => '(uid=a\\2a\\29\\28uid=\\2a)',
))
  PASS  settings are checked before the directory is switched on
  PASS  an empty password is refused before anything is sent (it would be an anonymous bind) (got array (
  0 => false,
  1 => 'empty name or password',
  2 => NULL,
), want array (
  0 => false,
  1 => 'empty name or password',
  2 => NULL,
))
  PASS  the service password stays out of backups and the audit trail
  (no LDAP server here: run with LENDRA_LDAP_URI and php-ldap for the live checks, as the release run does)

== 3.2.0 overflow (customer voice) · checks due: PAT tests, calibrations ==
  PASS  a date field becomes a due date (30 days ahead, not lent once passed); a text field cannot
  PASS  the list: passed and inside the window, soonest first; a date beyond the window is not listed (got array (
  0 => 
  array (
    0 => 'PAT-LATE',
    1 => 'PAT test',
    2 => true,
    3 => true,
  ),
  1 => 
  array (
    0 => 'CAL-LATE',
    1 => 'Calibration',
    2 => true,
    3 => false,
  ),
  2 => 
  array (
    0 => 'PAT-SOON',
    1 => 'PAT test',
    2 => false,
    3 => true,
  ),
), want array (
  0 => 
  array (
    0 => 'PAT-LATE',
    1 => 'PAT test',
    2 => true,
    3 => true,
  ),
  1 => 
  array (
    0 => 'CAL-LATE',
    1 => 'Calibration',
    2 => true,
    3 => false,
  ),
  2 => 
  array (
    0 => 'PAT-SOON',
    1 => 'PAT test',
    2 => false,
    3 => true,
  ),
))
  PASS  the check-out refuses an item past a "not lent once passed" date, naming it
  PASS  a passed date that is only a reminder does not stop the loan
  PASS  the desk's verdict says the same before anything is written
  PASS  recording the new date lets it go out again
  PASS  the page and the dashboard notice are wired

== 3.3.0 round 4 (customer voice) · the usage report ==
  PASS  the period: the last 30 days by default, ending today (got array (
  0 => '2026-08-28',
  1 => '2026-09-26',
), want array (
  0 => '2026-08-28',
  1 => '2026-09-26',
))
  PASS  a reversed period is put the right way round; an impossible date falls back (got array (
  0 => 
  array (
    0 => '2026-03-01',
    1 => '2026-03-10',
  ),
  1 => 
  array (
    0 => '2026-02-09',
    1 => '2026-03-10',
  ),
), want array (
  0 => 
  array (
    0 => '2026-03-01',
    1 => '2026-03-10',
  ),
  1 => 
  array (
    0 => '2026-02-09',
    1 => '2026-03-10',
  ),
))
  PASS  at most a year (got array (
  0 => '2025-03-10',
  1 => '2026-03-10',
), want array (
  0 => '2025-03-10',
  1 => '2026-03-10',
))
  PASS  by item: only the part of each loan inside the period counts; late = returned after due or still out past it; share of the period out (got array (
  0 => 
  array (
    0 => 'Camera X (R4-X)',
    1 => 2,
    2 => 1,
    3 => 3.0,
    4 => 1,
    5 => 30,
  ),
  1 => 
  array (
    0 => 'Camera Y (R4-Y)',
    1 => 1,
    2 => 1,
    3 => 1.5,
    4 => 1,
    5 => 15,
  ),
), want array (
  0 => 
  array (
    0 => 'Camera X (R4-X)',
    1 => 2,
    2 => 1,
    3 => 3.0,
    4 => 1,
    5 => 30,
  ),
  1 => 
  array (
    0 => 'Camera Y (R4-Y)',
    1 => 1,
    2 => 1,
    3 => 1.5,
    4 => 1,
    5 => 15,
  ),
))
  PASS  by category: measured against its items that are not retired (4.5 days of 2 × 10) (got array (
  0 => 
  array (
    0 => 'R4 Cameras',
    1 => 3,
    2 => 4.5,
    3 => 2,
    4 => 23,
  ),
), want array (
  0 => 
  array (
    0 => 'R4 Cameras',
    1 => 3,
    2 => 4.5,
    3 => 2,
    4 => 23,
  ),
))
  PASS  by department: loans, items and borrowers, no share (a department has no pool) (got array (
  0 => 
  array (
    0 => 'Media',
    1 => 3,
    2 => 2,
    3 => 1,
    4 => NULL,
  ),
), want array (
  0 => 
  array (
    0 => 'Media',
    1 => 3,
    2 => 2,
    3 => 1,
    4 => NULL,
  ),
))
  PASS  a period in the future counts nothing after now (got 0.5, want 0.5)
  PASS  the CSV: headings, then each cell through the formula guard (a negative number stays a number) (got 'Item,Days out
"\'=HYPERLINK(""x"")",-1.5
', want 'Item,Days out
"\'=HYPERLINK(""x"")",-1.5
')
  PASS  the page: a viewer reads it, with the period, the grouping and the CSV link
  PASS  the CSV through the front controller: grouped by borrower
  PASS  signed out, nothing

== 3.3.0 round 4 (customer voice) · restore to an earlier date from a backup this server keeps ==
  PASS  each stored backup has its own "Dry run" button
  PASS  the dry run reads the stored file straight from the server (no download, no upload), naming it
  PASS  a name that is not in the list (a path) is refused, and nothing is read
  PASS  a member cannot use it

== 3.3.0 · the import's dry run applies the import's own checks (W2's fleet finding) ==
  PASS  the dry run counts 2 of 4 ready: an impossible date and a tag repeated in the file are skipped, with the reasons (got array (
  0 => 4,
  1 => 2,
  2 => 
  array (
    3 => 'pat test is a date (yyyy-mm-dd)',
    4 => 'asset tag "R4-IMP-1" appears twice in this file',
  ),
), want array (
  0 => 4,
  1 => 2,
  2 => 
  array (
    3 => 'pat test is a date (yyyy-mm-dd)',
    4 => 'asset tag "R4-IMP-1" appears twice in this file',
  ),
))
  PASS  and it writes nothing (got 0, want 0)
  PASS  the import then does exactly what the dry run said (got array (
  0 => 2,
  1 => 
  array (
    3 => 'pat test is a date (yyyy-mm-dd)',
    4 => 'asset tag "R4-IMP-1" appears twice in this file',
  ),
  2 => 2,
), want array (
  0 => 2,
  1 => 
  array (
    3 => 'pat test is a date (yyyy-mm-dd)',
    4 => 'asset tag "R4-IMP-1" appears twice in this file',
  ),
  2 => 2,
))
  PASS  the upload screen uses the handler's non-writing branch, not run()'s own dry-run mode

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  PASS  updates: the updater table holds exactly one state row
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /updates is updates_page()
  PASS  updates: route post /updates/key is updates_key_save()
  PASS  updates: route post /updates/check is updates_check()
  PASS  updates: route post /updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Lendra with records seeds nothing and changes nothing
  PASS  A2: real use: one of the owner's own rows among the sample
  PASS  A2: remove: the owner's own row stays, and a sample row it points at is kept
  PASS  A2: remove: the report says what went, and what was kept because the owner's records use it
  PASS  A2: remove: the rest of the sample is gone
  PASS  A2: remove: every kept sample row keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
  PASS  A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
  PASS  A2: remove: the owner's own uploaded file is untouched
  PASS  A2: "Load demo data" on a Lendra with records refuses and deletes nothing
  PASS  A2: backup: written, sent to the bucket (a signed PUT), and read back
  PASS  A2: backup: the bucket secret is not in the backup it protects
  PASS  A2: backup: under the standard keep count (14) every backup is kept
  PASS  A2: switched off: with automatic backups off, a backup is never due however old the last one
  PASS  A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
  PASS  A2: an offsite endpoint must be https for an owner (plain http only in tests)
  PASS  A2: all 25 owner's-install checks reported
  PASS  A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
  PASS  A2: index.php loads the three cores and the wiring
  PASS  A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
  PASS  A2: route post /sample-data/remove is sample_data_remove()
  PASS  A2: route post /restore/backups is backups_save()
  PASS  A2: route post /restore/backup-now is backup_now()
  PASS  A2: route get /health is health_page()
  PASS  A2: sample_data_remove() opens with the restore page's admin gate
  PASS  A2: backups_save() opens with the restore page's admin gate
  PASS  A2: backup_now() opens with the restore page's admin gate
  PASS  A2: health_page() opens with the restore page's admin gate
  PASS  A2: every POST checks the CSRF token
  PASS  A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
  PASS  A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
  PASS  A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
  PASS  A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Lendra, removable)
  PASS  A2: the menu links to Health
  PASS  A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
  PASS  A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
  SKIP  A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 7 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (24 tables dumped), outside the 1 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the subject-access export, taken before the erase, shows every value the erase removes (7)
  PASS  erase planting · sqlite: the erase page (/borrowers/1) says what an erase keeps: "Kept: the audit trail's line that this borrower was added, w…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the audit trail's line that this borrower was added, w…"
499/499 PASS, 0 FAIL

← Back to Lendra · Manual · Quickstart · API