Ownware
Home›Lendra›Test run
Lendra · Test run · run 2026-09-25

Lendra Test run, as shipped in the download

333 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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  licence: every font ships the SIL OFL text for its own family beside it
  PASS  licence: 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 anonymised 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 fulfils 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 afterwards
  PASS  cancelling the holder passes the offer immediately
  PASS  a borrower holding an asset is detected (anonymise refuses)
  PASS  GDPR export returns the subject rows
  PASS  anonymise blanks name/email/phone/department
  PASS  loan history survives anonymisation (the assets still need a record)
  PASS  MCP initialize reports lendra 3.1.6
  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  anonymise refuses while the borrower still holds something
  PASS  the anonymise 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-v3.1.6
  PASS  healthz + api_ping + openapi report 3.1.6
  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 10 credentials this schema holds (settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.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
333/333 PASS, 0 FAIL

← Back to Lendra · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →