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

Assetora Test run, as shipped in the download

321 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.

== 1. parse_cents / fmt_cents: money <-> integer cents (string math, no float) ==
  PASS  "24000" -> 2400000 (got 2400000, want 2400000)
  PASS  "1,234.56" -> 123456 (got 123456, want 123456)
  PASS  "$8" -> 800 (got 800, want 800)
  PASS  "1.239" -> null (3dp) (got NULL, want NULL)
  PASS  2400000 -> "24,000.00" (got '24,000.00', want '24,000.00')
  PASS  7000 -> "70.00" (got '70.00', want '70.00')
  PASS  clamp_cents floors at 0 (got 0, want 0)
  PASS  clamp_int caps 15-digit (got 2000000000, want 2000000000)

== 2. Depreciation::normalize* + money + rate parser ==
  PASS  method: junk -> straight_line (got 'straight_line', want 'straight_line')
  PASS  method: REDUCING -> reducing_balance (got 'reducing_balance', want 'reducing_balance')
  PASS  status: junk -> active (got 'active', want 'active')
  PASS  status: DISPOSED -> disposed (got 'disposed', want 'disposed')
  PASS  methodLabel reducing (got 'Reducing balance', want 'Reducing balance')
  PASS  money $1,234.56 (got '$1,234.56', want '$1,234.56')
  PASS  money -$45.00 (sign before symbol) (got '-$45.00', want '-$45.00')
  PASS  money ZAR code prefix (got 'ZAR 200.00', want 'ZAR 200.00')
  PASS  parseRateBps "25" -> 2500 (got 2500, want 2500)
  PASS  parseRateBps "18.75%" -> 1875 (got 1875, want 1875)
  PASS  parseRateBps "2.5" -> 250 (got 250, want 250)
  PASS  parseRateBps ">100%" clamps to 10000 (got 10000, want 10000)
  PASS  parseRateBps "abc" -> null (got NULL, want NULL)
  PASS  fmtRatePct 1875 -> "18.75%" (got '18.75%', want '18.75%')

== 3. monthsInService: completed calendar months (anniversary method) ==
  PASS  Jan15 -> Feb15 = 1 (got 1, want 1)
  PASS  Jan15 -> Feb14 = 0 (not yet a month) (got 0, want 0)
  PASS  Jan15 -> Mar15 = 2 (got 2, want 2)
  PASS  same day = 0 (got 0, want 0)
  PASS  as-of before acq = 0 (got 0, want 0)
  PASS  2020-05-15 -> 2026-03-20 = 70 (got 70, want 70)
  PASS  2022-03-10 -> 2026-07-07 = 51 (got 51, want 51)
  PASS  full 5 years = 60 (got 60, want 60)
  PASS  Jan31 -> Feb28 = 1 (clamped, non-leap) (got 1, want 1)
  PASS  Jan31 -> Feb27 = 0 (not yet) (got 0, want 0)
  PASS  Jan31 -> Feb29 = 1 (leap year) (got 1, want 1)
  PASS  Jan31 -> Feb28 = 0 in a LEAP year (Feb 29 exists, not reached) (got 0, want 0)
  PASS  Jan30 -> Feb28 = 1 (clamped) (got 1, want 1)
  PASS  Mar31 -> Apr30 = 1 (30-day month clamp) (got 1, want 1)
  PASS  Jan31 -> Mar31 = 2 (day exists again) (got 2, want 2)
  PASS  clamp pair: Jan-28 and Jan-31 both = 1 month at Feb-28 close (got true, want true)

== 4. base: cost - salvage, floored, salvage capped at cost ==
  PASS  100000 - 7000 = 93000 (got 93000, want 93000)
  PASS  salvage > cost -> 0 (got 0, want 0)
  PASS  negative salvage -> full cost base (got 5000, want 5000)

== 5. straight-line accumulated + per-period: FINAL period absorbs the remainder ==
  PASS  SL accum m0 = 0 (got 0, want 0)
  PASS  SL accum m1 = 13285 (got 13285, want 13285)
  PASS  SL accum m6 = 79710 (got 79710, want 79710)
  PASS  SL accum m7 = 93000 (= base exactly) (got 93000, want 93000)
  PASS  SL accum past life stays base (got 93000, want 93000)
  PASS  SL period k1 = 13285 (got 13285, want 13285)
  PASS  SL final period k7 absorbs remainder = 13290 (got 13290, want 13290)
  PASS  SL periods sum to base (got 93000, want 93000)
  PASS  SL book at end == salvage exactly (got 7000, want 7000)

== 6. reducing-balance step + accum: integer cents, round-half-up, salvage floor ==
  PASS  rbStep 10% of 100000 = 10000 (got 10000, want 10000)
  PASS  rbStep 25% of 12345 rounds to 3086 (.25 down) (got 3086, want 3086)
  PASS  rbStep 25% of 12346 rounds up to 3087 (.50 up) (got 3087, want 3087)
  PASS  rbStep clamps to salvage floor (got 500, want 500)
  PASS  rbStep rate 0 -> 0 (got 0, want 0)
  PASS  rbStep no room (book==salvage) -> 0 (got 0, want 0)
  PASS  RB accum m1 = 10000 (got 10000, want 10000)
  PASS  RB book  m1 = 90000 (got 90000, want 90000)
  PASS  RB book  m2 = 81000 (got 81000, want 81000)
  PASS  RB book  m3 = 72900 (got 72900, want 72900)
  PASS  RB life cap: 2 periods only (got 81000, want 81000)
  PASS  RB long run book >= salvage
  PASS  RB long run accum = cost - book

== 7. asset-level accumFor / bookFor (disposal-aware) ==
  PASS  van months = 36 (got 36, want 36)
  PASS  van accum @36mo = 1679976 (got 1679976, want 1679976)
  PASS  van book @36mo = 1520024 (got 1520024, want 1520024)
  PASS  van book at end of life = salvage (got 400000, want 400000)
  PASS  disposed asset accum pinned at disposal date (got 1679976, want 1679976)
  PASS  disposed asset book pinned at disposal date (got 1520024, want 1520024)

== 8. disposal gain / loss = proceeds - book value at disposal (exact) ==
  PASS  forklift book at disposal = 845880 (got 845880, want 845880)
  PASS  forklift gain = 900000 - 845880 = 54120 (got 54120, want 54120)
  PASS  forklift gainLoss label = Gain (got 'Gain', want 'Gain')
  PASS  RB disposal book = 81000 (got 81000, want 81000)
  PASS  RB disposal gain = 4000 (got 4000, want 4000)
  PASS  RB disposal loss = -31000 (got -31000, want -31000)
  PASS  loss label (got 'Loss', want 'Loss')
  PASS  break-even label (got 'Break-even', want 'Break-even')

== 9. schedule: length, month labels, sum, final book == salvage (SL) ==
  PASS  schedule has 7 rows (got 7, want 7)
  PASS  row1 depreciation = 13285 (got 13285, want 13285)
  PASS  row7 depreciation = 13290 (remainder absorbed) (got 13290, want 13290)
  PASS  row7 accumulated = 93000 (got 93000, want 93000)
  PASS  row7 book = salvage 7000 (got 7000, want 7000)
  PASS  row1 month label = 2024-02 (got '2024-02', want '2024-02')
  PASS  schedule depreciation sums to base (got 93000, want 93000)
  PASS  disposed schedule last row flagged disposal
  PASS  disposed schedule final book = book at disposal (got 845880, want 845880)

== 10. periodDep: depreciation recognised WITHIN a report period ==
  PASS  dep in 2025 = 110000 (got 110000, want 110000)
  PASS  dep in 2026 = 10000 (got 10000, want 10000)
  PASS  two years of dep sum to base (got 120000, want 120000)

== 11. categoryRollup: excludes disposed, sums reconcile to PHP per-asset sum ==
  PASS  rollup has 2 categories (disposed excluded) (got 2, want 2)
  PASS  rollup total book reconciles to per-asset PHP sum (got 225000, want 225000)
  PASS  rollup total cost reconciles (got 350000, want 350000)
  PASS  rollup sorted by cost desc (IT 200000 first)

== 12. PRIME SWEEP: SL end-of-life exactness + book never below salvage ==
  PASS  SL prime sweep: end-of-life accum == base (all cases) (got true, want true)

== 13. PRIME SWEEP: reducing-balance book monotone-down and never below salvage ==
  PASS  RB sweep: monotone, floored at salvage, accum reconciles (got 0, want 0)

== 14. csvCell: numeric-aware formula-injection guard ==
  PASS  =SUM neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralised (got '\'+1', want '\'+1')
  PASS  -SUM neutralised (got '\'-SUM(A1)', want '\'-SUM(A1)')
  PASS  @cmd neutralised (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralised (got '\'	x', want '\'	x')
  PASS  -45.00 kept numeric (got '-45.00', want '-45.00')
  PASS  -2 kept numeric (got '-2', want '-2')
  PASS  1250 kept numeric (got '1250', want '1250')
  PASS  plain text untouched (got 'Delivery van', want 'Delivery van')
  PASS  mid-string = untouched (got 'a=b', want 'a=b')
  PASS  comma/quote RFC-4180 quoted (got '"Rack, ""v2"""', want '"Rack, ""v2"""')

== 15. toCsv: header + rows, RFC-4180, CRLF ==
  PASS  header row (got 'Asset,Cost,Book', want 'Asset,Cost,Book')
  PASS  row count (header+2) (got 3, want 3)
  PASS  CRLF line endings
  PASS  comma field quoted
  PASS  negative money stays numeric

== 16. csrf_valid: empty-token bypass rejected (hash_equals('','') is TRUE) ==
  PASS  empty stored + empty given -> REJECTED
  PASS  empty stored + any given -> REJECTED
  PASS  matching token -> accepted
  PASS  mismatched token -> rejected

== 17. Pdf: produces a valid, non-trivial PDF document ==
  PASS  starts with %PDF header
  PASS  ends with %%EOF
  PASS  has xref table
  PASS  has a page object
  PASS  non-trivial length
  PASS  long line wraps to 2+ chunks

== 18. App::view param-collision regression (the /report crash) ==
  PASS  payload key 'data' reaches the view (was dropped by the $data param collision)
  PASS  payload key 'tpl' reaches the view (the certora template_form collision class)
  PASS  payload key 'layout' reaches the view

== 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: no_smtp_cols
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: upgrade_from_1x
  PASS  v2: serializer_matches_core
  PASS  v2: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: API reuses the tested core (never re-derives validation or depreciation)
  PASS  v2: no ternary key re-reads (PLAN item 10)
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
  PASS  v3: API.md ships + sw cache is v3.1.5
  PASS  v2: cli-server base guard present (PLAN item 9)
  PASS  v3: v3_tables
  PASS  v3: v3_columns
  PASS  v3: upgrade_restores_v3_tables
  PASS  v3: update_on_a_missing_settings_row_silently_does_nothing
  PASS  v3: migrate_ensures_the_settings_singleton
  PASS  v3: asset_disposed_is_declared
  PASS  v3: asset_disposed_actually_fires
  PASS  v3: dispose_sets_the_status_and_date
  PASS  v3: dispose_reports_the_engines_gain_loss
  PASS  v3: dispose_is_audited
  PASS  v3: dispose_refuses_an_already_disposed_asset
  PASS  v3: refused_dispose_changed_nothing
  PASS  v3: mcp_totals_match_the_products_engine
  PASS  v3: mcp_detail_matches_the_engine
  PASS  v3: mcp_create_uses_the_forms_refusal
  PASS  v3: mcp_create_writes_the_asset
  PASS  v3: mcp_refuses_an_unknown_method_rather_than_coercing
  PASS  v3: unknown_role_gets_the_most_restrictive
  PASS  v3: empty_role_is_treated_as_legacy_admin
  PASS  v3: member_cannot_dispose
  PASS  v3: member_can_still_scan
  PASS  v3: viewer_cannot_write
  PASS  v3: last_admin_cannot_be_demoted
  PASS  v3: member_does_not_see_dispose_asset
  PASS  v3: member_calling_dispose_is_refused
  PASS  v3: member_dispose_wrote_nothing
  PASS  v3: review_date_is_the_end_of_the_last_scheduled_month
  PASS  v3: review_date_is_null_for_a_disposed_asset
  PASS  v3: assets_hold_no_personal_data
  PASS  v3: sighting_removes_an_asset_from_the_discrepancy_list
  PASS  v3: a_disposed_asset_is_not_a_discrepancy
  PASS  v3: a_second_sighting_of_the_same_asset_is_refused_by_the_schema
  PASS  v3: scan_resolves_a_label_url
  PASS  v3: scan_resolves_a_tag
  PASS  v3: scan_resolves_nothing_for_junk
  PASS  v3: register_pdf_engine_renders
  PASS  v3: label_sheet_renders
  PASS  v3: brand_css_brands_the_light_theme
  PASS  v3: brand_css_brands_the_dark_theme_too
  PASS  v3: brand_dark_accent_is_legible_on_the_dark_canvas
  PASS  v3: unbranded_install_emits_no_brand_css
  PASS  v3: scheduled_backup_writes_a_dated_file
  PASS  v3: scheduled_backup_redacts_credential_material
  PASS  v3: scheduled_backup_prunes_to_the_retention_count
  PASS  v3: audit_records_the_disposal
  PASS  v3: audit_detail_carries_no_secret
  PASS  v3: db_files_cleaned
  PASS  qr: qr_decodes_back_to_the_payload
  PASS  qr: qr_reed_solomon_parity_is_valid
  PASS  qr: qr_format_block_names_the_real_mask
  PASS  qr: qr_covers_versions_1_to_10
  PASS  qr: qr_refuses_an_over_long_payload
  PASS  qr: golden matrices unchanged (a refactor that alters output fails here)
  PASS  v3: the layout INJECTS the brand CSS (a helper nothing calls brands nothing)
  PASS  v3: the branding form exists and posts to the branding route
  PASS  v3: the logo is rendered, not merely stored
  PASS  v3: saved views are RENDERED on the register, not just writable
  PASS  v3: the register offers bulk actions
  PASS  v3: photos are rendered and uploadable on the asset page
  PASS  v3: the calendar feed and the scheduled backup are reachable (helpers with a route)
  PASS  v3: GET /mcp answers the transport hint BEFORE the key check
  PASS  v3: ONE disposal writer — every door goes through _asset_dispose_apply()
  PASS  v3: bulk owns NO write SQL of its own for disposal — it loops the shared writer
  PASS  v3: the PDFs and the feed read Depreciation:: rather than doing arithmetic
  PASS  v3: no view posts to a route that does not exist
  PASS  v3: every v3 route has a function to answer it
  PASS  v3: the deploy kit is filled and ships inside app/ (where package.sh will find it)
  PASS  v3: the nine cores are byte-identical to the reference copy (or skipped when unavailable)
  PASS  v3: README documents 3.0 and API.md gains the MCP section
  PASS  v3: the audit viewer reads the column the core actually writes
  PASS  v3: MCP domain refusals THROW McpRefusal, never return an error array
  PASS  v3: UI mutations + api-settings are role-gated, not merely logged in
  PASS  v3: no ungated write route beyond the by-design baseline (router-derived)
  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)

== UI kit: asset record + book-value timeline + period close ==
  PASS  kit: layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
  PASS  kit: no theme.js; style.css is an app layer with a dark accent; pre-kit scratch file removed
  PASS  kit: App::asset() is versioned by mtime and the service worker revalidates code
  PASS  kit: no font or script is fetched from another host (fonts bundled)
  PASS  routes: POST /period-close is registered, gated on settings.write and CSRF-checked
  PASS  hero: the asset record draws the book-value timeline from the SAME schedule the tables use
  PASS  dashboard: the first-run checklist and needs-your-action are computed from the DATABASE
  PASS  close/permission: a viewer and a member cannot close the register; an admin can
  PASS  close/happy: the close records the assets, the totals and the depreciation booked in the period
  PASS  close/frozen: what the register said is STORED, not recomputed on read
  PASS  close/refused: closing through a date already closed is refused, and names the date
  PASS  close/refused: a period cannot be closed into the future
  PASS  close/refused: a non-date is refused with a human sentence
  PASS  close/period figure: the second close books only the depreciation SINCE the first
  PASS  close/does not lock: the register still computes and still allows a correction afterwards

== v3.1: depreciation journal ==
  PASS  P6/happy: the journal debits each category and credits accumulated depreciation once
  PASS  P6/balances: debits equal credits to the cent
  PASS  P6/refusal: if the categories no longer add up to what the close FROZE, it refuses and says why
  PASS  P6/refusal: a period that booked no depreciation has no journal, and says so
  PASS  P6/format: the QuickBooks journal header is exact (got 'Journal No,Journal Date,Account,Debits,Credits,Description', want 'Journal No,Journal Date,Account,Debits,Credits,Description')
  PASS  P6/format: the Xero manual-journal header is exact (got 'Narration,Date,Description,AccountCode,Amount', want 'Narration,Date,Description,AccountCode,Amount')
  PASS  P6/format: Xero takes ONE signed amount per line — the credit is negative
  PASS  P6/format: journal amounts carry NO thousands separator and no injection prefix
  PASS  P6/format: a negative journal amount survives as a number, never as escaped text
  PASS  P6/format: amount() writes plain decimals (got '-1500.00', want '-1500.00')
  PASS  P6/happy: the generic journal puts the debit and credit in separate columns and dates every line
  PASS  P6/happy: the operator's own account names are used when set
  PASS  P6/edge: with nothing set it falls back to names a bookkeeper recognises, never to a made-up code
  PASS  P6/permission: the journal route is admin-gated (settings.write), like the close itself
  PASS  P6/idempotency: the journal is built from the close's FROZEN total, never recomputed from live assets

== 3.1.4: disposals in a closed period ==
  PASS  disposal/probe: Q1 closes with both assets and $800 of depreciation
  PASS  disposal/probe: Q2 books the disposed van's April–May $400 too ($1,000, not $600)
  PASS  disposal/probe: Q2 holds one asset — the van left the register on its disposal date
  PASS  disposal/probe: Q1's journal still builds after the later disposal (it used to refuse)
  PASS  disposal/journal: Q2's journal carries the full $1,000 of depreciation and balances
  PASS  disposal/journal: the van's disposal posts proceeds, accumulated depreciation, loss and cost out
  PASS  disposal/journal: the QuickBooks and Xero presets carry the disposal lines too
  PASS  disposal/journal: the operator's own disposal account names are used when set
  PASS  disposal/refusal: a disposal reinstated after the close makes the journal refuse, and says why
  PASS  disposal/legacy: a pre-3.1.4 close still exports as closed, and reports the $400 it left out
  PASS  disposal/report: the report shows a pre-3.1.4 close's left-out depreciation beside it
  PASS  close/held: an asset acquired after the close date is not counted as held at it
  PASS  demo seed: two closes, and the H1 2026 journal builds with both disposals in it
  PASS  demo seed: one finished stock-take with exactly one active asset not sighted
  PASS  demo reset: closes and stock-takes go with the register they describe
  PASS  demo guard: the hourly reseed runs after the controllers load, so the seed can close periods
  PASS  perm/reinstate: undoing a disposal takes the disposal permission and is audited
  PASS  perm/import: importing assets takes asset.write (the form and the POST)
  PASS  perm/backup: both backup downloads (JSON and the raw .sqlite with password hashes) are admin-only
  PASS  mcp: the AI connection names the server as the REST API does (assetora), on GET and POST alike
  PASS  form: the reducing-balance rate says it is MONTHLY and converts a typical annual rate
  PASS  perm/roles: a viewer has neither asset.write nor asset.dispose
  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: every MCP tool declares whether it reads or writes, so a read-only key can see something
  PASS  scope: the key form lets an administrator mint a read-only key
  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
  PASS  docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended licence
  PASS  webhooks: the Settings page answers and lists every event it offers
  PASS  webhooks: the Add, Pause, Resume and Delete buttons each reach a route and do what they say
  PASS  webhooks: a paused webhook is sent nothing
  PASS  webhooks: asset.created is sent by the browser action where it happens [302]
  PASS  webhooks: asset.disposed is sent by the browser action where it happens [302]
  PASS  webhooks: every event the Settings page offers is sent somewhere
  PASS  backup gate: the app answered under PHP's built-in server with no PHP error in its log
  PASS  backup gate: a signed-in viewer gets 403 on /backup.json and /backup.sqlite, and nothing is served
  PASS  backup gate: a signed-in member gets 403 on /backup.json and /backup.sqlite, and nothing is served
  PASS  backup gate: the administrator gets both backups (JSON and the SQLite file)
  PASS  backup gate: the JSON backup leaves out the 2FA seed, SMTP password, webhook secret and SSO secret
  PASS  backup gate: the scheduled-backup route refuses a missing and a wrong token
  PASS  backup gate: a signed-in viewer gets 403 on the restore page and on POST /restore, /restore/commit, /restore/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/restore\/backup-token":403},"csrf":true}]
  PASS  backup gate: a signed-in viewer without the token is refused by the scheduled-backup route
  PASS  backup gate: a signed-in member gets 403 on the restore page and on POST /restore, /restore/commit, /restore/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/restore\/backup-token":403},"csrf":true}]
  PASS  backup gate: a signed-in member without the token is refused by the scheduled-backup route
  PASS  backup gate: the administrator opens the restore page and is not refused on any restore or backup-token route [{"page":200,"posts":{"\/restore":302,"\/restore\/commit":302,"\/restore\/backup-token":302}}]
  PASS  backup gate: the JSON backup carries no reusable credential from any table (9 columns: api_keys.token_hash, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, settings.feed_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
  PASS  backup gate: each planted credential row is in the backup with the cell redacted, not dropped
  PASS  links: https behind a trusted TLS proxy (X-Forwarded-Proto or CF-Visitor); a direct client cannot claim it; plain http stays http
  PASS  links: nothing builds a scheme from $_SERVER[HTTPS] alone any more (src, controllers, views)
  PASS  demo ribbon: every page carries the admin wording (the product has no public page)
  PASS  demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens the card, and that choice holds for the session
  PASS  demo ribbon: injected just before </body>; output with no </body> (JSON, CSV, PDF) passes through untouched

————————————————————————————————————————————————————
ALL 321 TESTS PASSED (0 failed)

← Back to Assetora · 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 →