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