Cyresora · Test run · run 2026-11-07
Cyresora Test run, as shipped in the download
409 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.
== 1. Schema install + migrate (SQLite in-memory) == PASS schemaExists() after install PASS table users exists PASS table settings exists PASS table assets exists PASS table vulnerabilities exists PASS table incidents exists PASS table notifications exists PASS table contacts exists PASS table evidence exists PASS table api_keys exists PASS table webhooks exists PASS table invites exists PASS table audit_log exists PASS install() + migrate() are idempotent PASS api_keys.scope exists and defaults to full == 2. The instruments, and when they start applying == PASS CRA Article 14 applies from 11 September 2026 PASS NIS2 transposition deadline PASS five regimes are modeled (GDPR and DORA since 1.1.0) PASS CRA vulnerability track cites Article 14 PASS NIS2 cites Article 23 PASS eight distinct anchors exist: the CRA and NIS2 four, and GDPR's and DORA's own (1.1.0) PASS ...and they are the ones the instruments name PASS a CRA VULNERABILITY final report is 14 days... PASS ...counted from the corrective measure being available PASS a CRA SEVERE INCIDENT final report is 1 month... PASS ...counted from the submission of the notification PASS the two CRA tracks therefore differ in BOTH duration and anchor PASS NIS2 early warning is 24h from awareness PASS NIS2 notification is 72h from awareness PASS NIS2 final report is 1 month from the SUBMISSION PASS NIS2 has an on-request intermediate report PASS ...and a post-handling final report == 3. 24 and 72 hours are ABSOLUTE durations, computed on UTC == PASS 24 hours PASS 72 hours PASS across a month boundary PASS across a year boundary PASS DST cannot touch a 24-hour deadline (EU clocks go back 2026-10-25) PASS ...nor a 72-hour one PASS hoursBetween is signed PASS ...and negative going backwards == 4. `+1 month` from 31 January — the bug that makes a deadline LATER than the law == PASS PHP overflows 31 Jan + 1 month (this is the hazard) PASS Clock clamps it to the last day of February PASS ...and to the 29th in a leap year PASS 31 March + 1 month is 30 April PASS 31 May + 1 month is 30 June PASS a date that exists in both months is untouched PASS December rolls the year PASS 30 January is fine, 28 February PASS one month across a DST change keeps the local wall-clock time PASS ...which is 00:00 local on both sides of the change PASS 14 days is a calendar span in the business timezone == 5. There is no working-day carve-out, and the register says so == PASS Friday 18:00 + 24h lands on Saturday PASS ...and the register flags it as a weekend deadline PASS Friday + 72h lands on Monday and is not flagged PASS a Sunday is a weekend PASS a Wednesday is not PASS the weekend test is applied in the BUSINESS timezone, not UTC == 6. The ladder: states, and the deadline that has not started == PASS the CRA vulnerability ladder has three stages PASS early warning is due 24h after awareness PASS ...and is still running at hour 10 PASS notification is due 72h after awareness PASS THE FINAL REPORT HAS NO DUE DATE YET PASS ...because its state is pending, not overdue PASS ...and the register names what starts it PASS once a measure is available, the 14 days can be computed PASS ...and the clock is running PASS at hour 30 the 24-hour early warning is overdue PASS ...by six hours, as a negative number PASS the 72-hour notification is still open PASS four hours out, the early warning is due_soon PASS filed before the deadline is submitted PASS filed after it is submitted_late — recorded, not quietly forgiven == 7. The one-month clocks run from the submission instant == PASS the notification was submitted at hour 40 PASS so the final report is due one month from THAT, not from the 72-hour deadline PASS ...which is a different, EARLIER date than anchoring to the deadline would give PASS ...and the difference is exactly the 32 hours they filed early == 8. NIS2 Article 23(4)(c) only exists when the authority asks == PASS unrequested, the intermediate report has no state to act on PASS ...and no due date PASS once requested, it is on the board PASS the post-handling final report waits for the handling to complete PASS worst() surfaces the overdue stage PASS worst() prefers due_soon over open PASS everything filed reads as submitted PASS tones are the kit's own vocabulary PASS a late filing is shown as bad, not as done PASS humanLeft reads in hours under two days PASS ...and in days and hours beyond that PASS ...and says "ago" once it is blown PASS ...and copes with nothing to say == 9. The register, through the real schema == PASS an incident can run two regimes at once PASS ...and gets a ladder for each PASS an unknown regime is dropped rather than crashing PASS a recorded submission anchors the one-month clock through the database too PASS ...and the OTHER regime on the same incident is unaffected by it PASS filing the same stage twice is refused by the database PASS ...but the same stage under a DIFFERENT regime is fine == 10. What the register refuses to record == PASS an incident needs a title PASS ...and a moment of awareness PASS a DATE with no time is refused, because a 24-hour deadline cannot be computed from one PASS an incident with no regime has no clock, so it is refused PASS an unknown regime is refused PASS a good incident passes PASS a local time is converted to UTC for storage PASS ...and back again for the form PASS a UTC install is a no-op == 11. Roles — working an incident is not declaring that a report went out == PASS viewer may NOT incident.edit PASS viewer may NOT notify.submit PASS member may incident.edit PASS member may vuln.edit PASS member may evidence.attach PASS member may NOT notify.submit PASS member may NOT settings.edit PASS responder may notify.submit PASS responder may contact.edit PASS responder may NOT settings.edit PASS admin may notify.submit PASS admin may settings.edit == 12. The documents an authority is handed == PASS a formula is neutralized PASS a plain negative number stays a number PASS ordinary text is untouched PASS the incident report is a real PDF PASS ...whose streams inflate to readable text PASS ...with the key/value block populated (Pdf::kv takes PAIRS, not an associative array) PASS ...naming the incident PASS ...carrying both regime ladders PASS ...stating that the times are UTC PASS ...and warning that the clocks do not pause at a weekend PASS ...and that NIS2 is a Directive whose deadlines are national == 13. Source-level regressions == PASS every route is registered through App::get/App::post (112 routes) PASS no route is registered by a bare function call PASS every routed handler exists PASS every static and Pdf method called exists PASS no handler reaches for a non-existent App::param() PASS Clock never reads the system time (comments stripped before scanning) PASS the calendar-month clamp is present and commented PASS the application dates are pinned, not computed PASS no controller accepts a due date as input PASS every ladder is built by Clock::ladder() PASS Api::requireKey selects the key scope PASS ...and refuses a write on a read-only key at the door PASS Api::mintKey persists the chosen scope PASS the MCP endpoint hands the scope to the core PASS all six MCP tools are declared PASS 1.1.0 adds three more, all read-only: draft_report, list_risks, list_documents PASS ...and every one declares a handler the core can call PASS no tool uses a key the core does not read PASS every read-only MCP tool declares readOnly => true PASS the write tools are role-gated PASS a created resource returns 201 through Api::json PASS uploads are sniffed by magic bytes PASS the stored filename is minted PASS the serve path is containment-checked AND re-sniffed PASS nosniff is set on served evidence PASS backup redaction covers password_hash PASS backup redaction covers totp_secret PASS backup redaction covers totp_recovery PASS backup redaction covers token_hash PASS backup redaction covers secret PASS backup redaction covers smtp_pass PASS backup redaction covers backup_token_hash PASS backup redaction covers oidc_client_secret PASS one redaction list, used by both backup doors PASS no view loads anything from an external host PASS no view pulls a font or script from a CDN PASS permanent info alerts are nested so the kit cannot toast them away: contacts.php PASS permanent info alerts are nested so the kit cannot toast them away: dashboard.php PASS permanent info alerts are nested so the kit cannot toast them away: incident_draft.php PASS permanent info alerts are nested so the kit cannot toast them away: layout.php PASS the service worker cache is versioned for this release PASS ...and never caches a page navigation PASS the manifest names this product PASS the layout loads the kit and groups the nav PASS the kit itself is untouched PASS the session cookie is named for THIS product PASS no build-environment trace: jay... PASS no build-environment trace: aero... PASS no build-environment trace: eir... PASS no build-environment trace: clean... PASS no build-environment trace: mission... PASS no trace of a spine product: aiac... PASS no trace of a spine product: compl... PASS no trace of a spine product: compl... == 14. The demo board is alive, and shows every state at once == PASS 1.1.0 demo: five risks across the Article 21(2) areas, and three documents with five versions kept, as real PDFs PASS 1.1.0 demo: the incident response plan is past its review, and the continuity plan has a version waiting to take effect PASS five incidents (the fifth, since 1.1.0, a personal-data breach on the GDPR clock) PASS ...its GDPR clock runs from the later breach awareness, 48 hours left PASS four products and systems PASS a notification rota with a deputy PASS the board shows an OVERDUE deadline PASS ...a clock still running PASS ...something already submitted PASS ...something submitted LATE PASS ...and a deadline that has NOT STARTED because no corrective measure exists yet PASS the dashboard counts at least one overdue PASS ...and at least one exploited vulnerability with no fix PASS no seeded incident is in the future PASS no seeded notification is in the future PASS no seeded notification precedes its own incident PASS reset clears the register but not the login 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 == F2. The warning setting, and deadline alerts to the rota == PASS warn hours: a 24 h setting marks an early warning 20 h out as due soon PASS warn hours: the default 6 h leaves it running PASS warn hours: the saved setting reaches the board (24 h → due soon, 6 h → running) — it was saved and never read PASS alert points: parsed descending, de-duplicated, nonsense dropped PASS alert points: empty stays empty PASS alert rung: 13 h out reaches no point PASS alert rung: 11 h out is the 12 h point PASS alert rung: 3 h out is the TIGHTEST point reached (4), not every point PASS alert rung: under an hour is the 1 h point PASS alert rung: just overdue is point 0 PASS alert rung: overdue for days alerts no more (switching alerts on does not mail old history) PASS alert rung: a submitted stage never alerts PASS alert rung: a stage whose clock has not started never alerts PASS alert recipients: the regime's notifier and their deputy, nobody else PASS alerts: switched off (the default), nothing is sent PASS alerts: on, a clock 3 h from its deadline fires ONE alert at the 4 h point, to 2 recipients PASS alerts: with no mail server the alert is recorded as no-smtp, never as sent PASS alerts: a second run (cron after a page load) sends nothing — one latch PASS alerts: the Clocks screen is told alerts are on but cannot be delivered PASS alerts: a deadline that has just passed alerts once more (point 0), through the owner's SMTP PASS alerts: a failed send keeps its reason for the Clocks screen PASS alerts: a stage overdue for weeks does not suddenly alert PASS alerts: the email names the stage, the incident, the deadline and says Cyresora files nothing PASS alerts: the email carries no credential or SMTP detail PASS alerts: deadline.alert is a webhook event an endpoint can subscribe to PASS alerts: a cron route runs them, guarded by the backup token PASS alerts: the Clocks screen runs the watcher and shows its health PASS webhooks: notification.recorded fires from the REST route and the agent tool too, after the write PASS webhooks: incident.opened fires from the agent tool too (open_incident), after the write PASS settings: the mail-server copy no longer claims invitations are emailed PASS dashboard: a zero "Due within hours" tile no longer says "act now" PASS backup guard: the harness made one signed-in account per role (viewer,member,responder,admin) PASS backup guard: GET /backup.json — 403 for every non-admin role, open to admin PASS backup guard: GET /backup.sqlite — 403 for every non-admin role, open to admin PASS backup guard: GET /restore — 403 for every non-admin role, open to admin PASS backup guard: POST /restore/dry-run — 403 for every non-admin role, open to admin PASS backup guard: POST /restore/commit — 403 for every non-admin role, open to admin PASS backup guard: POST /restore/token — 403 for every non-admin role, open to admin PASS backup guard: GET /backup/scheduled without its token refuses every signed-in role, admin included PASS backup guard: GET /alerts/run without its token refuses every signed-in role, admin included PASS webhooks: "Add" reaches its route and saves the endpoint (was a 404) PASS webhooks: "Pause" reaches its route and flips the endpoint PASS webhooks: "Delete" reaches its route and removes the endpoint PASS demo card: no page here is public, so every page keeps "signed in as the admin" PASS demo card: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session PASS demo card: boot() hooks it only behind the .demo-mode marker, and a page with no </body> passes through untouched PASS backup redaction: a canary sits in every credential column of the schema (api_keys.token_hash, backup_state.s3_secret, invites.token_hash, settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, updater_state.license_api_key, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret) PASS backup redaction: each arrives as [REDACTED] and no canary is anywhere in the JSON backup == 1.1.0 · GDPR Article 33 and DORA on the same incident, each with its own anchors == PASS GDPR and DORA are modeled, with the dates they apply from PASS GDPR: one stage, the notification to the supervisory authority, 72 h after awareness of the breach PASS GDPR: a later awareness of the personal data breach moves only the GDPR clock PASS DORA: not classified as major yet — the initial notification has no invented date PASS DORA: classified at hour 2 — 4 h from the classification PASS DORA: classified at hour 22 — capped at 24 h from awareness (Article 5(1)) PASS DORA: classified at hour 30 — 4 h from the classification (Article 5(2)) PASS DORA: the intermediate report runs 72 h from the SUBMITTED initial notification PASS ...and the final report waits for the intermediate one PASS DORA: the final report is one calendar month after the intermediate report PASS DORA: the note says the weekend extension of Article 5(4) is not applied PASS the form's local times are stored as UTC (Berlin, summer time) PASS one incident, three regimes, three ladders PASS ...GDPR from the breach awareness, DORA from the classification, NIS2 from the incident awareness PASS a recorded DORA initial notification starts the 72-hour intermediate clock through the database PASS cleared: GDPR falls back to the incident awareness; the DORA initial notification stays submitted PASS the API record names both new anchors and carries the GDPR and DORA ladders PASS the form refuses a classification before awareness PASS ...and a date without a time PASS the authority pre-filled per regime family == 1.1.0 · what to send, drafted: per-stage drafts from the incident record == PASS NIS2 early warning draft: the two things Article 23(4)(a) asks, answered from the record PASS NIS2 notification draft: updates the early warning, severity, and names the indicators of compromise as a gap PASS GDPR draft: the four points of Article 33(3), with the counts and consequences marked to complete PASS ...and once the 72 hours have passed, it asks for the reasons for the delay (Article 33(1)) PASS CRA vulnerability final report draft: the vulnerability, the malicious-actor gap, and the remedy from the register PASS CRA early warning draft: product with its version, and the Member States as a gap PASS DORA draft: says the template of Implementing Regulation (EU) 2025/302 decides, and asks for the classification time PASS a field the record holds is filled, not marked: adding the root cause removes that gap PASS no draft for a regime the incident does not run, or a stage that does not exist PASS router setup: an admin, a viewer, a responder and a member; an incident on NIS2 and GDPR PASS the incident page links a draft for each stage still to file PASS a viewer reads the draft, read-only, with no way to record it as sent PASS a responder gets the draft in the recording form, the authority pre-filled PASS an unknown stage is a 404 PASS recording from the draft keeps the text as sent, as the record of the notification PASS ...after which the draft page says it is already recorded, and offers no second record PASS API: a read key gets the GDPR draft with its gaps listed PASS API: a regime the incident does not run is a 422 == 1.1.0 · the risk register == PASS score is likelihood × impact on the 1-5 scales; out of scale is 0 PASS bands: 15+ high, 8+ medium, 1+ low PASS ten Article 21(2) areas, (a) to (j) PASS a risk needs a name PASS ...and both scales in range PASS ...a known area PASS ...and a review date that is a date PASS summary: the grid counts open risks only; areas with none are listed; closed risks raise no review or lateness PASS a viewer reads the register PASS ...with no way to add or edit PASS a viewer cannot add a risk PASS a member adds a risk; it is audited PASS an out-of-scale likelihood is refused in the form, nothing stored PASS an edit records the score and status change in the audit trail PASS the register shows the score, the residual, and the areas with none recorded PASS the CSV carries the register and defuses a formula PASS API /api/risks: highest score first, with the uncovered areas PASS removing a risk is audited == 1.1.0 · the document hub: versions kept, the file decides its type, reviews dated == PASS the bytes decide: PDF, Word, OpenDocument, Markdown PASS ...and refuse an executable named .pdf, a spreadsheet ZIP, and binary named .txt PASS versions are stored with the SHA-256 of the file kept PASS a second version 2.0, a date that is not a date, and a fake PDF are refused PASS the version in force is the latest effective one; review 12 months after it: within 30 days PASS ...on an earlier day, the earlier version was in force and 2.0 was upcoming PASS ...review overdue once the date has passed; none when the interval is 0 PASS a document whose only version is in the future has nothing in force PASS a viewer sees the hub, the version in force, and no upload form PASS the document page lists the version with its approver and hash PASS the version downloads byte for byte PASS an upload without a file says so PASS a member edits the details; audited PASS a member cannot remove a document and its history PASS API /api/documents: current version with its hash and the review state PASS an admin removes it: rows and files gone, audited PASS the document checks leave nothing behind in data/uploads == 1.1.0 · assets from Microsoft Entra ID (Azure AD) and other directory exports == PASS dry run on an Entra "Download devices" CSV: 2 to add, a nameless row and a repeated deviceId skipped, nothing written PASS the import lands each device as an internal system: OS and model, version, owner, the device id kept, scope unset PASS next month's export updates the same rows (no duplicates) and never touches the scope you marked PASS a missing name column is named, with the columns the file does have PASS another export, semicolon-separated, imports through the column names you type PASS router: the import page offers the Entra ID export PASS router: no file, a clear refusal PASS router: a viewer cannot import PASS the router checks leave no data/imports folder behind PASS the demo reset removes the stored demo documents PASS import dry run: a row with an owner email that is not an address is counted as skipped, not ready, and nothing is written (ready 1, skipped 1) == 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 Cyresora 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 (5 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 Cyresora 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: the owner's "keep" count is honored (2 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 Cyresora, 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 4 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 (25 tables dumped), outside the 1 column(s) kept by ruling (possible legal records) PASS erase planting · sqlite: the alert log keeps the colleague who was also told PASS erase planting · sqlite: a responder cannot erase: HTTP 403, nothing changed PASS erase planting · sqlite: a responder cannot open the Data requests page: HTTP 403 PASS erase planting · sqlite: an admin is refused while the person is on the alert rota for an open incident, and nothing changes PASS erase planting · sqlite: an admin opens the Data requests page and the person is listed PASS erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the audit trail's lines that this person was added to …" PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the audit trail's lines that this person was added to …" ==================================================== ALL 409 TESTS PASSED (0 failed)