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