Compare commits

..
Author SHA1 Message Date
OpenClaw 96ec0c2411 fix(economic): sanitize user-input fields to prevent 400 errors
E-conomic API returns HTTP 400 when text-line descriptions contain certain
characters. The most common case is '/' in the order reference field,
which causes the entire draft-invoice export to fail.

This change adds a single sanitizer class (economic_export_sanitizer) that
handles all user-input fields flowing into e-conomic:

  - sanitizeTextLine() — for plain text lines (reference, notes, po, reg_*, etc.)
  - sanitizeProductNumber() — for product identifiers
  - sanitizeProductDescription() — for product-line descriptions
  - sanitizeForEconApi() — catch-all

Sanitization rules:
  - '/' is replaced with '-' (the reported 400 trigger)
  - Control characters (\x00-\x1F except \t and \n) are stripped
  - Tab and newline characters collapse to a single space
  - Whitespace is normalized and trimmed
  - Lengths capped (text 250, product 50, description 500) with '...' suffix

Applied to all vulnerable fields in economic_invoice_draft.php:
  - order.po
  - order.reference (PRIMARY FIX for the reported issue)
  - order.notes
  - order.reg_1/2/3
  - order_item.reference
  - order_item.notes
  - product.description
  - product.productNumber
  - department_name

Test coverage:
  - 31 unit tests with 45 assertions
  - All edge cases (null, empty, control chars, multibyte, very long)
  - Lint and test suite both pass

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196
2026-08-17 10:13:46 +00:00
935b2d58ce fix(api): remove broken Coolify cron-worker auto-deploy (#389)
## Summary

The Coolify-based auto-deployment of a separate `cron` worker app after
every API deploy was never reliable. This PR removes the ~800 lines of
dead auto-deploy logic from `release_manager.php` while keeping the
underlying cron mechanism (`cron_worker.php`, `cron_scheduler.php`, the
docker-compose `cron-worker` service) intact.

## Changes

- **`release_manager.php`** (-818 lines)
- Removed 19 private methods: `deployCronWorker*`, `cronWorker*`,
`cronWorkerAutoprovision*`, etc.
- Removed 3 constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`,
`CRON_WORKER_DESIRED_COUNT`
- Kept `cronWorkerStatus()` but rewrote as a direct DB query (no Coolify
dependency)
- **`tests/Unit/ReleaseManager/ReleaseManagerTest.php`** (-208 lines,
removed 9 cron-worker tests)
- **`tests/Unit/Cron/CronWorkerWiringTest.php`** (rewritten — now
asserts removed wiring is GONE)
- **`docs/CRON_PLAN.md`** (new — comprehensive plan)

## What replaced the broken auto-deploy

- The cron worker runs as part of the main API docker-compose stack (the
`cron-worker` service is unchanged)
- New verification cron `1bb56ba8-2f3e-4bea-baa2-39801ea88ea8` runs
`/workspace/scripts/verify-api-cron.py` every 5 min
- Alerts to Slack #ai-daily (`C0AM3E43249`) if no fresh heartbeat in 10+
min

## Test results

- 4/4 cron tests pass
- 54/54 ReleaseManager tests pass
- Full Unit suite: **1279 passed** (same 7 pre-existing failures on
master, unchanged)
- `php -l` passes on all modified files

## Plan

See `docs/CRON_PLAN.md` for the full audit, plan, and acceptance
criteria.

🤖 Generated with [OpenClaw](https://docs.openclaw.ai)

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-17 10:16:03 +02:00
Jeppe Bandbugfix 4c6b60c9f2 fix(api): self-healing schema bootstrap on every request (TRU-77 follow-up) (#387)
## Summary

Makes the database **self-healing** — every request auto-runs all
`*_schema_bootstrap::ensureSchema()` after `$db->connect()`. This
catches the "merged-to-master-but-migration-never-applied-to-prod"
failure mode that just bit us with TRU-77 (`invoice_email` column).

## Why

PR #383 added a pre-deploy schema step to `deploy.yml`. Correct, but
requires GitHub secrets (`DEPLOY_SSH_KEY`, `DEPLOY_USER`,
`SMOKE_BASE_URL`) that aren't set on the `api` repo yet. Until those
secrets exist, the pre-deploy step is skipped and migrations never reach
production. Result: API still references `invoice_email` but the column
doesn't exist → `Unknown column 'invoice_email' in 'SELECT'`.

## Fix

- New class `classes/schema_bootstrap_runtime.php`:
  - Auto-discovers all `*_schema_bootstrap.php` files in `classes/`
  - Calls `ensureSchema()` on each
  - Memoized per PHP process (`private static bool $ran = false`)
  - One failure does not block others (logged, not thrown)
- New hook in `services/nginx/app/index.php` right after
`$db->connect()`:
  ```php
  try {
      \classes\schema_bootstrap_runtime::runAll();
  } catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' .
$e->getMessage());
  }
  ```
- New test: `tests/Unit/SchemaBootstrapRuntimeTest.php` (3 cases)

## Safety

Each existing `*_schema_bootstrap` is **additive + idempotent**:
- `SHOW COLUMNS` check before any `ALTER`
- `ALTER TABLE ADD COLUMN` only if missing
- Per-class `private static bool $initialized = false` short-circuit
- Errors logged but never break the request

So: first request after deploy adds missing columns. Every subsequent
request hits the in-process `$ran` short-circuit (~microseconds). The
new column then exists, the API works, error goes away.

## Test plan

1. Wait for CI (PHP unit + integration)
2. Merge to master
3. Production auto-deploys (or manual re-deploy if secrets not set)
4. Hit the failing endpoint — first request will auto-migrate, response
should be 200
5. Verify with `GET /api/admin/schema-check` that all columns are
present

## Rollback

If anything goes wrong, revert the merge commit. The runtime class only
auto-discovers files matching `*_schema_bootstrap.php`; removing it
reverts the system to the pre-deploy-step-only behavior.

---

**Closes** the TRU-77 follow-up: the "Unknown column 'invoice_email' in
'SELECT'" error should never recur, because the code now self-heals
regardless of whether the deploy pre-deploy step ran.

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 23:00:03 +02:00
18092b271e feat(api): schema health check + pre-deploy migration runner (fixes TRU-77 production error) (#383)
## Problem

Production was returning:
```json
{"success":false,"data":{"message":"Internal server error: Unknown column 'invoice_email' in 'SELECT'"}}
```
when authenticating as a superuser. The migration that adds
`users.invoice_email` was merged to master in api#381 but never applied
to the production database.

## Fix

- New `GET /api/admin/schema-check` endpoint — returns 503 with explicit
list of missing columns if any are absent (instead of a generic 500)
- `scripts/run-schema-bootstraps.php` — auto-discovers and runs every
`*_schema_bootstrap` class on the live database (additive, idempotent)
- `scripts/schema-health-check.php` — CLI tool for the same check, used
by deploy pipelines
- New Pest contract test `SchemaHealthCheckTest` — verifies the test DB
has every required users column and the schema-check endpoint works
- `deploy.yml`: pre-deploy step runs the bootstrap runner, smoke test
also runs the schema check, Slack alert on failure

## What this prevents

- Future migrations being merged without being applied to production
- Silent failures (generic 500) when a column is missing
- Repeated manual investigation of the same root cause

Refs: TRU-77 (the original bug), api#381 (the original PR)

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: bugfix-subagent <bugfix-subagent@truckwash.local>
Co-authored-by: OpenClaw Bugfix Agent <bugfix@openclaw.local>
2026-08-16 22:30:03 +02:00
Jeppe Bandbugfix 76ad696691 docs: mark pen-test plan as CANCELLED (TRU-80, no budget approved) (#386)
## Summary

Marks the pen-test plan document as **CANCELLED** per Jeppe's
instruction 2026-08-16 20:00 UTC.

External pen-test engagement is **not** happening at this time (no
budget approved). The plan document is kept as a planning artefact for
future reference, but explicitly bannered as CANCELLED so future agents
and engineers do not assume this is an active project.

## Changes

- Added  CANCELLED banner to the top of
`documentation/security/pen-test-plan.md`
- Banner includes: status, reason, meaning, owner, and how to re-open in
the future
- Original content preserved below the banner (296 lines → 304 lines
with banner)

## Context

- TRU-80 (Linear): remains in **Done** state (planning artefact
complete, execution not authorised)
- Qodana Cloud: remains active (no workflow changes)
- GitHub Dependabot + secret scanning: remain active (free tier)
- This PR supersedes PR #385 (which was rolled back because it also
removed Qodana by mistake)

## Checklist

- [x] No external vendor will be engaged
- [x] No workflow changes
- [x] No secret removals
- [x] Original plan content preserved

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 22:20:03 +02:00
16 changed files with 1543 additions and 1128 deletions
+31
View File
@@ -89,12 +89,43 @@ jobs:
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Pre-deploy schema check (run all *_schema_bootstrap)
id: pre_schema
run: |
echo "Running schema bootstraps against the live database…"
# Idempotent — adds missing columns, never drops anything.
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
# production failure mode (TRU-77) where migrations were
# merged to master but never applied to the live DB.
php scripts/run-schema-bootstraps.php
echo "Schema bootstraps complete."
- name: Alert Slack if schema-check fails (pre-deploy)
if: failure()
run: |
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
https://slack.com/api/chat.postMessage \
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
- name: Smoke test
id: smoke
continue-on-error: true
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
# Also hit the new admin schema-check endpoint to verify
# no required columns are missing.
echo "::group::Schema health check"
php scripts/schema-health-check.php | tee /tmp/schema-report.json
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
echo "::error::Schema health check FAILED — missing columns:"
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
exit 1
fi
echo "Schema health check OK."
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure'
+115
View File
@@ -0,0 +1,115 @@
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
## Audit findings
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
- That separate app runs `php index.php run cron-worker` as a long-running process
- Tracks worker heartbeats in a `cron_worker_state` table
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
for creating a new application for the cron worker are not stable/reliable in
our setup.
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
is sound. The Docker compose files already define a `cron-worker` service
that runs the long-running process. The auto-deploy logic is just trying to
maintain a separate Coolify app for the same purpose — and failing.
## The plan
### 1. Remove the broken auto-deploy logic
Delete or no-op the following from `release_manager.php`:
- `cronWorkerStatus()`
- `deployCronWorker()`
- `deployCronWorkerForApiTarget()`
- `deployCronWorkerAfterApiDeployment()`
- `cronWorkerAutoprovisionEnabled()`
- `cronWorkerAutoprovisionRequired()`
- `cronWorkerTarget*()` (5 methods)
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
- `cronWorkerDeployContext()`
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
- `cronWorkerChannels()`, `cronWorkersForTarget()`
- `cronWorkerSourceFromCronTarget()`
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
- The `$result['cron_worker'] = ...` call after API deployment
Keep:
- `cron_worker.php` class (the actual worker)
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
- The `cron-worker` service in `docker-compose*.yml`
- The `cron-worker` case in `cli.php`
### 2. Remove the corresponding tests
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
### 3. Add a reliable 5-min cron mechanism
Two-layer approach:
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
tasks that need to run frequently (60s intervals, etc.). Started automatically
with the rest of the stack.
2. **System cron / health-check loop** — verifies the cron-worker is alive every
5 min. If no fresh heartbeat in 10 min, alert.
This replaces the broken auto-deploy with a simple, observable contract.
### 4. Add a verification harness
`/workspace/scripts/verify-api-cron.py`:
- Hits the API's `cronWorkerStatus` endpoint
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
- If no fresh heartbeat in 10 min, post to #ai-daily
- Run every 5 min via a new cron job
### 5. Update documentation
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
- `openapi.yaml` — remove `cron_worker_status` route documentation
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
## Acceptance criteria
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
- [ ] No tests reference removed methods
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
- [ ] PR created, tests pass, merge
## Risk
- **Removing `deployCronWorker*` could break live deployments** if someone is
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
HTTP route returning a friendly "removed" message instead of deleting it.
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
break dashboards. Mitigation: replace the route handler with a direct query
to `cron_worker_state` so the response shape is preserved.
## Steps
1. Create a feature branch `fix/remove-coolify-cron-worker`
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
6. Edit `cli.php`: no change needed (cron-worker case still works)
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
11. Run the test suite locally
12. Push branch, create PR, get user review
+304
View File
@@ -0,0 +1,304 @@
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
> ## ⛔ CANCELLED — DO NOT EXECUTE
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
>
> ---
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
**Project:** UI Library & Pen Testing
**Priority:** Medium
**Status (this doc):** Draft v1 — ready for engineering + management review
**Author:** bugfix sub-agent (TRU-80)
**Date:** 2026-08-16
---
## 1. Purpose
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
is intended to validate the security posture of the customer- and operator-facing
production stack before further public rollout and ahead of any major commercial
expansion (e.g. additional self-serve sites, additional payment integrations).
This document is the planning artefact for TRU-80. It does **not** itself perform
or simulate a pen test — it specifies the engagement so that an external vendor can
be selected and contracted.
---
## 2. Scope (in)
The following systems are **in scope** for the engagement. Coverage is **production
stack only** (no staging is exposed for pen-test unless explicitly noted).
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
- Authentication / session endpoints, including:
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
`subusersRoute.php`, `limitedBackofficeRoute.php`
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
- Authorization model: role-based access (customer / sub-user / backoffice /
superuser) and per-customer data isolation.
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
collected-invoices, order routes.
- Payment integration: Stripe module (`moduleStripeRoute.php`).
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
token handling, invoice push.
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
Redis trait (`redis_t.php`).
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
our code consumes it; the upstream WP instance is **out of scope** unless
hosted by us.
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
`coolify` deploy config. Black-box reachable attack surface only.
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
- API client and token storage in the SPA (where tokens live, at-rest
protection, refresh flow).
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
the root (`openapi.yaml`).
- Capacitor deep-link / universal-link / custom-scheme handling
(`capacitor.config.ts`).
### 2.3 Infrastructure & cross-cutting (in)
- TLS configuration (cert chain, HSTS, cipher suites) on the production
public host.
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
Permissions-Policy, X-Content-Type-Options).
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
- Email & SMS notification paths only as far as they can be abused for
spoofing / phishing of our users (we control the From domain).
### 2.4 Out of scope (explicitly)
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
not the third party itself.
- Internal office LAN, employee laptops, MDT, and physical site hardware
(gate controllers, scanners) — these are covered by a separate physical /
OT scope and **out of scope** for this IT pen test.
- Denial-of-service / load testing.
- Social engineering of Truck Wash staff.
- Source-code review of `node_modules` / vendor dependencies (the engagement
will use SCA tooling to flag known CVEs, but not audit transitive deps).
- Any production data exfiltration — the vendor will be given sanitised or
test accounts and synthetic data only.
---
## 3. Methodology
Industry-standard, manual-led engagement with tooling support. Recommended
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
use **OWASP MASVS** as the checklist.
Phases (estimated total: 12 working days of vendor effort, see §6):
1. **Scoping & recon (1 day)**
- Confirm target list, accounts, and rules of engagement.
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
- Active recon limited to non-destructive fingerprinting.
2. **API pen test (3 days)**
- AuthN/AuthZ boundary testing on every route group in §2.1.
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
wash certificates, sub-users, customer notes).
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
deserialisation, header injection.
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
discount stacking, sub-user privilege escalation.
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
3. **Web SPA pen test (2 days)**
- XSS (reflected, stored, DOM-based) including Vue template injection.
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
- Open-redirect / OAuth misconfig in any SSO flow.
- CSP / SRI effectiveness.
4. **Mobile (Capacitor) review (2 days)**
- Static analysis of the built APK / IPA (Capacitor WebView).
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
custom-scheme handlers).
- Local storage of tokens, biometric bypass if implemented.
- Deep-link / universal-link hijack attempts.
5. **Infrastructure & config (1.5 days)**
- TLS, headers, cookie flags, HSTS preload eligibility.
- NGINX hardening review (based on provided config snapshots).
- Docker / coolify surface only as externally reachable.
6. **SCA / dependency check (0.5 day)**
- `composer.json` and `package.json` SCA scan.
- High-severity known-CVE report only; no deep audit.
7. **Exploitation & PoC (1 day)**
- Build proofs-of-concept for any Critical / High findings.
8. **Reporting & re-test (1 day)**
- Draft report → vendor walkthrough → final report.
- Re-test of fixed findings is scoped separately (see §6).
---
## 4. Rules of engagement (RoE)
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
exploitation only with prior written approval per critical finding.
- **Contact channel:** shared Signal thread + email; vendor given a Slack
guest account in a dedicated `#sec-pentest-2026Q4` channel.
- **Stop conditions:** any finding that risks data loss, payment integrity,
or production gate operation → immediate stop + phone call to on-call.
- **Data handling:** vendor may only use synthetic / test data. No
exfiltration of real customer PII. All artifacts returned or destroyed at
end of engagement (TBD in contract).
- **Coverage of third parties:** the vendor will not test Stripe / Economic
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
we follow responsible-disclosure to the vendor ourselves.
---
## 5. Deliverables
1. **Kick-off doc** (this plan, signed off by both parties).
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
findings list).
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
Critical / High so we can start patching in parallel.
4. **Final report (PDF + JSON)** including:
- Executive summary, risk heatmap, business-impact narrative.
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
screenshots / Burp session, recommended fix, references.
- SCA dependency report as an appendix.
5. **Re-test letter** (separate SOW, see §6).
6. **Knowledge transfer**: 60-min session for engineering on the top 5
findings.
---
## 6. Budget & scheduling
### 6.1 Indicative effort
| Phase | Days | Notes |
| --- | --- | --- |
| 1. Scoping & recon | 1.0 | joint with us |
| 2. API pen test | 3.0 | |
| 3. Web SPA | 2.0 | |
| 4. Mobile (Capacitor) | 2.0 | |
| 5. Infra & config | 1.5 | |
| 6. SCA | 0.5 | tooling-led |
| 7. Exploitation / PoC | 1.0 | |
| 8. Reporting | 1.0 | incl. 1 review round |
| **Total** | **12.0 days** | |
### 6.2 Indicative cost (DKK, ex. VAT)
Pricing varies significantly with vendor. Three realistic budget tiers for
procurement:
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
| --- | --- | --- | --- |
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 16 000 | **144 000 192 000** | Best fit for our stack size, Danish-language reporting available. |
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 22 000 | **180 000 264 000** | More brand name, more bureaucracy, stronger report templates. |
| Top-tier / Big-4 style | 25 000 40 000 | **300 000 480 000** | Overkill for current footprint; revisit at Series-A. |
**Recommended envelope: 180 000 220 000 DKK** (mid-tier, 12 days) plus a
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
report).
Add ~5 000 DKK contingency for incident-response hours if a Critical is
found mid-engagement.
### 6.3 Schedule (proposed)
- **2026-08-25** — this plan reviewed and signed off by management.
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
proposals, evaluate.
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
- **2026-09-22 (week 39)** — engagement kick-off.
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
weeks, vendor working in parallel with their normal cadence).
- **2026-10-08** — draft report.
- **2026-10-15** — final report + walkthrough.
- **2026-11-15** — re-test (retainer).
All dates are **provisional** until a vendor is selected.
### 6.4 Vendor shortlist (candidates to approach)
We will request proposals from at least 3 of the following (final shortlist
to be confirmed with management):
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
for enterprise due-diligence.
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
---
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
We should land these before the vendor starts — they reduce noise and let
the vendor focus on real issues:
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
Gateway webhooks
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
- [ ] Rate-limit on auth, password reset, and OTP endpoints
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
- [ ] File-server (`file_server.php`) path-traversal tests in CI
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
high+ vulns
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
removed
- [ ] Secrets: no production keys in repo (`git log -S` audit)
This list is also the basis for re-test acceptance criteria.
---
## 8. Open questions for management
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
2. Confirm legal/procurement owner and contract template.
3. Confirm whether to require a Danish-language final report (recommended).
4. Confirm re-test budget is approved up-front, or per-finding.
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
6-day "API + SPA only" first pass.
---
## 9. References
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
- OWASP MASVS — https://mas.owasp.org/MASVS/
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
---
*This document is a planning artefact, not the test itself. Once approved, a
separate SOW will be drafted with the selected vendor and linked from this
issue.*
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Pre-deploy schema bootstrap runner.
*
* Loads and runs every `*_schema_bootstrap` class so the production
* database has all the columns the current code expects. Each
* bootstrap is additive and idempotent — safe to run on every deploy.
*
* Run via:
* php scripts/run-schema-bootstraps.php
*
* Used in .github/workflows/deploy.yml as a pre-deploy step.
*
* When you add a new *_schema_bootstrap class, you don't need to
* edit this file — the runner auto-discovers any class whose name
* ends in `_schema_bootstrap`.
*/
namespace scripts;
// Load the app entry point so $db is wired up the same way as in
// normal request handling.
$index = __DIR__ . '/../services/nginx/app/index.php';
if (!file_exists($index)) {
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
exit(2);
}
require_once $index;
$classesDir = __DIR__ . '/../services/nginx/app/classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
if (!$bootstraps) {
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
exit(0);
}
$ran = 0;
$skipped = 0;
foreach ($bootstraps as $file) {
require_once $file;
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (!class_exists($class)) {
fwrite(STDERR, " [skip] {$base}: class not found\n");
$skipped++;
continue;
}
if (!method_exists($class, 'ensureSchema')) {
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
$skipped++;
continue;
}
try {
$class::ensureSchema();
echo " [ok] {$base}\n";
$ran++;
} catch (\Throwable $e) {
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
exit(1);
}
}
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
/**
* Schema health check — verifies all required DB columns exist.
*
* Run via:
* GET /api/admin/schema-check (returns JSON report)
* php scripts/schema-health-check.php (CLI, exits 0/1)
*
* Lists the columns that the code expects to find in each critical
* table. If a column is missing, the response is 503 (HTTP) or
* exit code 1 (CLI) — clearly distinct from a generic 500.
*
* Add to the list when introducing a new optional column.
*/
namespace scripts;
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
use classes\customer_invoice_email_schema_bootstrap;
const SCHEMA_REQUIREMENTS = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
function check_schema(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
// First: run the schema bootstrap (additive, idempotent) so we
// give the DB a chance to self-heal.
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
$report['tables_checked']++;
// Confirm the table itself exists
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
// CLI mode
if (PHP_SAPI === 'cli') {
$report = check_schema();
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
exit($report['ok'] ? 0 : 1);
}
@@ -0,0 +1,111 @@
<?php
namespace classes;
/**
* Sanitizes user-input fields that are sent to the e-conomic API.
*
* Background: e-conomic returns 400 errors when description fields contain
* certain characters. The known issue is "/" in the order reference field
* (TRU-188), but we sanitize defensively for all such cases.
*
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
* - sanitizeProductNumber(): for product identifiers
* - sanitizeProductDescription(): for product-line descriptions
* - sanitizeForEconApi(): catch-all for arbitrary user input
*/
class economic_export_sanitizer
{
/** E-conomic soft limit for a single description line. */
public const TEXT_LINE_MAX_LENGTH = 250;
/** E-conomic soft limit for a product description. */
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
/** E-conomic soft limit for a product number. */
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
/** Characters that are illegal in product numbers on most e-conomic setups. */
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
/**
* Sanitize a value for use in a single-line text description.
*
* Transformations (in order):
* 1. Replaces "/" with "-" (the reported 400 trigger)
* 2. Strips control characters (\x00-\x1F) except \t and \n
* 3. Replaces tab with single space
* 4. Collapses newlines into spaces (text lines are single-line)
* 5. Collapses runs of spaces to a single space
* 6. Trims leading/trailing whitespace
* 7. Truncates to $maxLength with "..." suffix if needed
*/
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
// 1. Strip control characters except \t and \n
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
// 2. Replace tab with single space
$text = str_replace("\t", ' ', $text);
// 3. Collapse newlines to single space (text lines are single-line)
$text = preg_replace('/[\r\n]+/u', ' ', $text);
// 4. Replace forward slashes (the reported 400 trigger)
$text = str_replace('/', '-', $text);
// 5. Collapse runs of spaces
$text = preg_replace('/\s+/u', ' ', $text);
// 6. Trim
$text = trim($text);
// 7. Truncate with ellipsis if too long
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength - 3) . '...';
} elseif (mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength);
}
return $text;
}
/**
* Sanitize a product number/identifier.
*
* Removes characters that are illegal in product numbers on most
* e-conomic setups (filesystem-unsafe + path separators).
*/
public static function sanitizeProductNumber(mixed $value): string
{
if ($value === null) {
return '';
}
$text = (string)$value;
if ($text === '') {
return '';
}
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
$text = trim($text);
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
}
return $text;
}
/**
* Sanitize a longer product description.
*/
public static function sanitizeProductDescription(mixed $value): string
{
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
}
/**
* Catch-all sanitizer for any user-input value going to e-conomic.
* Defaults to text-line rules.
*/
public static function sanitizeForEconApi(mixed $value): string
{
return self::sanitizeTextLine($value);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
<?php
namespace classes;
/**
* Self-healing schema bootstrap.
*
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
* production database always has the columns the current code expects.
* This catches the "merged-to-master-but-never-applied-to-prod" failure
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
* step didn't run (missing GitHub secrets, network glitch, etc.).
*
* Each bootstrap is **additive + idempotent**:
* - SHOW COLUMNS check before any ALTER
* - ALTER TABLE ADD COLUMN only if missing
* - Once `ensureSchema()` has been called once for a class, the static
* `$initialized` flag short-circuits subsequent calls
*
* The discovery + run loop itself is memoized per PHP process via
* `self::$ran`, so the cost after the first request is a single
* `class_exists` check (~microseconds).
*
* Errors in a single bootstrap are logged but never throw — a broken
* migration must not 500 every request. A future /api/admin/schema-check
* call will surface the failure.
*/
class schema_bootstrap_runtime
{
/** @var bool Memoization for the discovery+run loop */
private static bool $ran = false;
/** @var string[] Class names that already failed this process (don't retry) */
private static array $failed = [];
public static function runAll(): void
{
if (self::$ran) {
return;
}
self::$ran = true;
$classesDir = __DIR__;
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
if (!$bootstraps) {
return;
}
foreach ($bootstraps as $file) {
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (in_array($class, self::$failed, true)) {
continue;
}
try {
if (!class_exists($class)) {
require_once $file;
}
if (!class_exists($class)) {
continue;
}
if (!method_exists($class, 'ensureSchema')) {
continue;
}
$class::ensureSchema();
} catch (\Throwable $e) {
self::$failed[] = $class;
error_log(sprintf(
'[schema-bootstrap] %s failed: %s',
$base,
$e->getMessage()
));
// Intentionally do not throw — a broken migration must
// not 500 every request. The next /api/admin/schema-check
// call (or the next deploy's pre-deploy step) will
// surface the failure.
}
}
}
}
+12
View File
@@ -213,6 +213,18 @@ try {
$response->error($e->getMessage(), 500);
}
// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema()
// once per process. Each is additive + idempotent (SHOW COLUMNS check before
// any ALTER), so this is safe on every request. Catches the
// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77
// invoice_email) even when the deploy pipeline pre-deploy step is skipped
// (missing GitHub secrets, network glitch, manual deploy, etc.).
try {
\classes\schema_bootstrap_runtime::runAll();
} catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage());
}
try {
release_manager::initializeRequestContext();
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
@@ -185,45 +185,55 @@ class economic_invoice_draft
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
// Parse the date of the transaction.
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
// Sanitize the department name (could contain "/" or other chars)
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
// Add the text line to the draft invoice
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a PO number, add it to the invoice
if ($order->po->value() !== '') {
self::addTextLine('PO: ' . $order->po->value());
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
}
// If there's a reference, add it to the invoice
if ($order->reference->value() !== '') {
$reference_value = $order->reference->value();
if ($reference_value !== '') {
self::addTextLine('Reference:');
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
if (str_contains($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->reference->value());
self::addTextLine('# ' . $reference_sanitized);
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '')
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
if ($order->reg_2->value() !== '')
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
if ($order->reg_3->value() !== '')
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
}
// Add the line to the invoice (If there's any registration numbers)
if ($line_reg !== '')
self::addTextLine($line_reg);
// If there's a note, add it to the invoice
if ($order->notes->value() !== '') {
$notes_value = $order->notes->value();
if ($notes_value !== '') {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->notes->value(), "\n")) {
foreach ( explode("\n", $order->notes->value()) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
if (str_contains($notes_sanitized, "\n")) {
foreach ( explode("\n", $notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->notes->value());
self::addTextLine('# ' . $notes_sanitized);
}
}
}
@@ -341,26 +351,28 @@ class economic_invoice_draft
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
self::addTextLine('Reference:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['reference'], "\n")) {
foreach ( explode("\n", $order_item['reference']) as $line ) {
// Sanitize the reference (handles "/" → "-" per TRU-188)
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
if (str_contains($item_reference_sanitized, "\n")) {
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['reference']);
self::addTextLine('# ' . $item_reference_sanitized);
}
}
// If there's a note, add it to the line
if (!empty($order_item['notes'])) {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order_item['notes'], "\n")) {
foreach ( explode("\n", $order_item['notes']) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
if (str_contains($item_notes_sanitized, "\n")) {
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order_item['notes']);
self::addTextLine('# ' . $item_notes_sanitized);
}
}
@@ -470,6 +482,9 @@ class economic_invoice_draft
*/
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
{
// Sanitize product identifier and description (defense in depth — also done at addLines())
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
// Add a line to the invoice
$line = [
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
* The schema health check verifies that all required DB columns exist
* for the routes the code references. If a column is missing (e.g. a
* migration wasn't run on production), the endpoint returns 503 with
* a clear list of missing columns — much more useful than a generic
* 500 with "Unknown column" hidden in the stack trace.
*/
class adminRoute
{
use route_t;
public function run(): void
{
// Schema health check — used by deploy pipelines, monitoring,
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} catch (\Throwable $e) {
// Bootstrap may fail in environments where $db is
// not yet wired up; report and continue with check
}
}
$report = $this->runSchemaCheck();
$response->setStatus($report['ok'] ? 200 : 503);
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
});
}
/**
* Returns ['ok' => bool, 'missing' => array, ...].
* If ok=false, the deploy should be blocked.
*/
private function runSchemaCheck(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
$requirements = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
foreach ($requirements as $table => $columns) {
$report['tables_checked']++;
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
}
@@ -1,9 +1,14 @@
<?php
// Test that the cron mechanism is properly wired. The Coolify auto-deploy logic
// was removed from release_manager.php 2026-08-17, so this test no longer asserts
// anything about cron worker deployment. The actual cron mechanism
// (cron_worker.php, cron_scheduler.php, cli.php, cronRoute.php) is unchanged.
$cronAppRoot = dirname(__DIR__, 3);
require_once $cronAppRoot . '/classes/cron_worker.php';
it('wires cron workers through schema, CLI, scheduler, and superuser routes', function (): void {
it('wires cron workers through schema, scheduler, CLI, and routes', function (): void {
$appRoot = dirname(__DIR__, 3);
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS') ?: dirname($appRoot, 3);
$schema = file_get_contents($appRoot . '/classes/cron_schema_bootstrap.php');
@@ -11,12 +16,6 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
$scheduler = file_get_contents($appRoot . '/classes/cron_scheduler.php');
$cli = file_get_contents($appRoot . '/cli.php');
$route = file_get_contents($appRoot . '/routes/cronRoute.php');
$manager = file_get_contents($appRoot . '/classes/release_manager.php');
$composeFiles = [
$repoRoot . '/docker-compose.yml',
$repoRoot . '/docker-compose.example.yml',
$repoRoot . '/docker-compose.prod.standalone.yml',
];
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS cron_worker_state');
expect($schema)->toContain('last_heartbeat_at');
@@ -47,29 +46,24 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
expect($cli)->toContain('new \\classes\\cron_worker()');
expect($route)->toContain('/superuser/cron/workers');
expect($route)->toContain('/superuser/cron/workers/deploy');
expect($route)->toContain('$response->success($result, 202)');
expect($route)->toContain('queueTaskRun(');
expect($route)->toContain('$response->success($run, 202)');
expect($route)->toContain('superuser_cron_view');
expect($route)->toContain('superuser_cron_manage');
expect($route)->toContain('superuser_coolify_manage');
});
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
expect($manager)->toContain('deployment_kind = \'cron_worker\'');
expect($manager)->toContain('createCronWorkerDeploymentRecord');
expect($manager)->toContain('waiting_for_heartbeat');
expect($manager)->toContain('cronWorkerAutoprovisionRequired');
expect($manager)->toContain('cron_worker_autoprovision_disabled');
expect($manager)->toContain('cron_worker_deploy_failed');
expect($manager)->toContain('auto_deploy = 0');
it('starts the cron-worker service via the docker-compose entrypoint', function (): void {
$appRoot = dirname(__DIR__, 3);
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS') ?: dirname($appRoot, 3);
$composeFiles = [
$repoRoot . '/docker-compose.yml',
$repoRoot . '/docker-compose.example.yml',
$repoRoot . '/docker-compose.prod.standalone.yml',
];
foreach ($composeFiles as $composeFile) {
$compose = file_get_contents($composeFile);
expect(str_contains($compose, 'command: ["php", "index.php", "run", "cron-worker"]'))->toBeTrue();
expect(str_contains($compose, 'while true; do php index.php run cron; sleep 60; done'))->toBeFalse();
}
});
@@ -104,3 +98,18 @@ it('reports consecutive scheduler loops as once-per-minute execution proof', fun
$result = $publicWorker->invoke($worker, $row);
expect($result['minute_cadence']['verified'])->toBeFalse();
});
it('exposes a cron status endpoint that no longer references Coolify auto-deploy', function (): void {
$appRoot = dirname(__DIR__, 3);
$manager = file_get_contents($appRoot . '/classes/release_manager.php');
// The Coolify auto-deploy constants and methods must be gone
expect($manager)->not->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->not->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->not->toContain('function deployCronWorker');
expect($manager)->not->toContain('function deployCronWorkerAfterApiDeployment');
expect($manager)->not->toContain('function cronWorkerAutoprovision');
expect($manager)->not->toContain('function cronWorkerHealth');
// The cronWorkerStatus method should still exist as a thin DB wrapper
expect($manager)->toContain('public function cronWorkerStatus');
expect($manager)->toContain("'coolify_auto_deploy_enabled' => false");
});
@@ -0,0 +1,224 @@
<?php
namespace tests\Unit\Economic;
use classes\economic_export_sanitizer;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
class EconomicExportSanitizerTest extends TestCase
{
// ========================================================================
// sanitizeTextLine
// ========================================================================
public function testSlashIsReplacedWithDash(): void
{
$this->assertSame('ABC-123-XYZ', economic_export_sanitizer::sanitizeTextLine('ABC/123/XYZ'));
$this->assertSame('Order 1 - 2 - 3', economic_export_sanitizer::sanitizeTextLine('Order 1 / 2 / 3'));
$this->assertSame('-leading and trailing-', economic_export_sanitizer::sanitizeTextLine('/leading and trailing/'));
}
public function testControlCharactersAreStripped(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x00lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x01lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x1Flo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x7F\x7Flo"));
}
public function testTabIsReplacedWithSpace(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine("a\tb\tc"));
}
public function testNewlinesCollapsedToSpace(): void
{
$this->assertSame('line1 line2 line3', economic_export_sanitizer::sanitizeTextLine("line1\nline2\nline3"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\n\n\nline2"));
}
public function testMultipleSpacesCollapsed(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine('a b c'));
}
public function testTrimsLeadingAndTrailingWhitespace(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine(' hello '));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("\n\thello\n\t"));
}
public function testTruncatesAtMaxLengthWithEllipsis(): void
{
$text = str_repeat('a', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 250);
$this->assertSame(250, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testTruncatesAtMaxLengthWithoutEllipsisWhenTooShort(): void
{
// When maxLength is 3, no room for ellipsis
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeTextLine($text, 3);
$this->assertSame(3, mb_strlen($result));
$this->assertSame('aaa', $result);
}
public function testDoesNotTruncateWhenShorterThanMaxLength(): void
{
$this->assertSame('short text', economic_export_sanitizer::sanitizeTextLine('short text', 250));
}
public function testNullReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(null));
}
public function testEmptyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(''));
}
public function testWhitespaceOnlyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(" \t\n "));
}
public function testWhitespaceOnlyWithSlashesReturnsEmptyString(): void
{
// After all transformations, "///" becomes "---"
// After trim of whitespace-only, " / " becomes "" (since / is replaced but space was there)
// Actually let's see: " / " -> " " stays; " - " -> "-"; then trim -> "-"
// So it doesn't become empty in this case. Let me re-test:
$result = economic_export_sanitizer::sanitizeTextLine(' / ');
$this->assertSame('-', $result);
}
public function testHandlesMultibyteChars(): void
{
$this->assertSame('æøå', economic_export_sanitizer::sanitizeTextLine('æøå'));
$this->assertSame('中文', economic_export_sanitizer::sanitizeTextLine('中文'));
$this->assertSame('🚗 car', economic_export_sanitizer::sanitizeTextLine('🚗 car'));
}
public function testTruncationRespectsMultibyteBoundaries(): void
{
$text = str_repeat('æ', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 10);
$this->assertSame(10, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testHtmlTagsAreNotStripped(): void
{
// We don't strip HTML — that's a different concern (XSS). We just sanitize for e-conomic.
// The "/" in </b> gets replaced with "-" (per the rules).
$this->assertSame('<b>notags<-b>', economic_export_sanitizer::sanitizeTextLine('<b>notags</b>'));
}
public function testSlashesInTheMiddleOfValueAreReplaced(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeTextLine('foo/bar/baz'));
}
public function testMultipleProblemCharsCombined(): void
{
$input = "AB/\nC\t\rD\x00E ";
$result = economic_export_sanitizer::sanitizeTextLine($input);
// After: strip control -> "AB/\nC\tDE ", tab->space -> "AB/\nC DE ",
// newline->space -> "AB/ C DE ", slash->dash -> "AB- C DE ",
// collapse spaces -> "AB- C DE ", trim -> "AB- C DE"
$this->assertSame('AB- C DE', $result);
}
public function testIntegerIsConvertedToString(): void
{
$this->assertSame('42', economic_export_sanitizer::sanitizeTextLine(42));
}
public function testFloatIsConvertedToString(): void
{
$this->assertSame('3.14', economic_export_sanitizer::sanitizeTextLine(3.14));
}
// ========================================================================
// sanitizeProductNumber
// ========================================================================
public function testProductNumberRemovesPathSeparators(): void
{
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC/DEF'));
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC\\DEF'));
}
public function testProductNumberRemovesForbiddenChars(): void
{
$input = "PROD:01?*<>|\"";
$result = economic_export_sanitizer::sanitizeProductNumber($input);
$this->assertSame('PROD01', $result);
}
public function testProductNumberTruncatesAt50Chars(): void
{
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeProductNumber($text);
$this->assertSame(50, mb_strlen($result));
}
public function testProductNumberTrimsWhitespace(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber(' PROD01 '));
}
public function testProductNumberStripsControlChars(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber("PROD\x0001"));
}
public function testProductNumberNullReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber(null));
}
public function testProductNumberAllForbiddenReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber('///\\\\::'));
}
public function testProductNumberKeepsDotsAndDashes(): void
{
$this->assertSame('PROD-01.0', economic_export_sanitizer::sanitizeProductNumber('PROD-01.0'));
}
// ========================================================================
// sanitizeProductDescription
// ========================================================================
public function testProductDescriptionTruncatesAt500(): void
{
$text = str_repeat('a', 1000);
$result = economic_export_sanitizer::sanitizeProductDescription($text);
$this->assertSame(500, mb_strlen($result));
}
public function testProductDescriptionReplacesSlashes(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeProductDescription('foo/bar/baz'));
}
// ========================================================================
// sanitizeForEconApi
// ========================================================================
public function testSanitizeForEconApiIsAliasForTextLine(): void
{
$this->assertSame(
economic_export_sanitizer::sanitizeTextLine('foo/bar'),
economic_export_sanitizer::sanitizeForEconApi('foo/bar')
);
}
}
@@ -411,214 +411,6 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
expect($payload)->not->toHaveKey('is_static');
});
it('creates private Coolify application payloads for cron workers', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'cron',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'auto_deploy' => 0,
], [
'coolify_service_name' => 'release-internal-cron-worker',
'coolify_project_uuid' => 'project-internal',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_build_pack' => 'dockerfile',
'coolify_deploy_now' => true,
'coolify_start_command' => 'php index.php run cron-worker',
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-node3',
]);
expect($payload['name'])->toBe('release-internal-cron-worker');
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($payload['start_command'])->toBe('php index.php run cron-worker');
expect($payload['is_auto_deploy_enabled'])->toBeFalse();
expect($payload)->not->toHaveKey('domains');
expect($payload)->not->toHaveKey('is_force_https_enabled');
});
it('derives cron worker deployment context from the API target without public routing', function (): void {
$manager = new release_manager();
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
$context = $contextMethod->invoke($manager, [
'id' => 17,
'channel_id' => 3,
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_environment_name' => 'production',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'manual_endpoint_host' => 'manual.example.test',
]),
], null, '5555555555555555555555555555555555555555', 41);
expect($context['coolify_auto_create'])->toBeTrue();
expect($context['coolify_resource_type'])->toBe('application');
expect($context['coolify_build_pack'])->toBe('dockerfile');
expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
expect($context['coolify_enable_ssl'])->toBeFalse();
expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($context)->not->toHaveKey('coolify_public_url');
expect($context)->not->toHaveKey('manual_endpoint_host');
});
it('requires Coolify cron worker autoprovisioning for API deployments by default', function (): void {
$manager = new release_manager();
$enabledMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionEnabled');
$requiredMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionRequired');
expect($enabledMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
expect($requiredMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
$optionalTarget = [
'deploy_context_json' => json_encode([
'cron_worker_autoprovision_required' => false,
]),
];
expect($enabledMethod->invoke($manager, $optionalTarget))->toBeTrue();
expect($requiredMethod->invoke($manager, $optionalTarget))->toBeFalse();
$disabledTarget = [
'deploy_context_json' => json_encode([
'cron_worker_autoprovision' => false,
]),
];
expect($enabledMethod->invoke($manager, $disabledTarget))->toBeFalse();
expect($requiredMethod->invoke($manager, $disabledTarget))->toBeFalse();
$managerSource = file_get_contents(app_path('classes/release_manager.php'));
expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
});
it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
$manager = new release_manager();
$healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');
expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deploying',
'created_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('deploying');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('waiting_for_heartbeat');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [
['status' => 'running', 'stale' => false],
], ['running' => 1, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
])['state'])->toBe('healthy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => '2020-01-01 00:00:00',
])['state'])->toBe('failed');
});
it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
$manager = new release_manager();
$operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');
expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
->toBe('deployment-123');
expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']]))
->toBe('operation-456');
expect($operationMethod->invoke($manager, ['message' => 'queued']))
->toBeNull();
});
it('detects missing Coolify cron worker resources from provider errors', function (): void {
$method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse();
});
it('classifies missing Coolify cron worker resources as repairable', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, [
'id' => 17,
'app' => 'api',
'coolify_instance_id' => 3,
'repository' => 'copenhagentruckwash/api',
], [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource');
});
it('repairs from an existing cron target when the API target is absent', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target');
});
it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => null,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => '',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('blocked');
expect($result['can_deploy'])->toBeFalse();
expect(array_column($result['issues'], 'code'))->toContain('missing_api_target');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
@@ -0,0 +1,51 @@
<?php
namespace tests\Unit;
use classes\schema_bootstrap_runtime;
use PHPUnit\Framework\TestCase;
/**
* Verifies the self-healing schema bootstrap runtime:
* 1. Discovers and calls every *_schema_bootstrap::ensureSchema() in classes/
* 2. Is idempotent (does not re-run within the same process)
* 3. Does not throw if a bootstrap throws (logs and moves on)
*
* The actual DB-touching work is exercised in production; here we
* stub the global $db so the columnExists() check inside each
* ensureSchema() can be observed.
*/
class SchemaBootstrapRuntimeTest extends TestCase
{
public function testRunAllDiscoversAndInvokesEachBootstrap(): void
{
$classesDir = __DIR__ . '/../../classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
$this->assertNotEmpty($bootstraps, 'No *_schema_bootstrap.php files found in classes/');
// Ensure no real $db is required: each ensureSchema() in the
// existing classes guards with `if (!isset($db) ...) { return; }`
// so they are no-ops without one. We just verify the runtime
// doesn't throw.
schema_bootstrap_runtime::runAll();
$this->assertTrue(true); // no exception
}
public function testRunAllIsIdempotent(): void
{
// First call already happened in test 1; calling again must
// short-circuit and not throw.
schema_bootstrap_runtime::runAll();
schema_bootstrap_runtime::runAll();
$this->assertTrue(true);
}
public function testNoOpWhenNoBootstrapsExist(): void
{
// Reflection: ensure runAll() is robust even if a different
// classes dir somehow had no bootstraps. We just call it
// again — it should be a no-op due to the static $ran flag.
schema_bootstrap_runtime::runAll();
$this->assertTrue(true);
}
}
@@ -0,0 +1,220 @@
<?php
/**
* Contract test: every column that the code expects to find in the
* `users` table must exist. Catches the production failure mode
* where a migration was added to code but never run on the database
* (e.g. "Unknown column 'invoice_email' in 'SELECT'" — TRU-77).
*
* This test runs against the test database (configured in
* phpunit.xml / Pest configuration). It does NOT run against
* production — that's covered by the `/admin/schema-check` HTTP
* endpoint in `adminRoute.php` which the deploy pipeline hits.
*/
app_require('classes/customer_invoice_email_schema_bootstrap.php');
use classes\customer_invoice_email_schema_bootstrap;
const REQUIRED_USERS_COLUMNS = [
// TRU-77 (added 2026-08-16) — the column that was missing in
// production after the migration was merged to master.
'invoice_email',
// Older required columns that the code references.
'wash_certificate_email',
'email',
'customer_number',
'phone_country_code',
'phone',
'group_id',
'created_at',
];
/**
* The unit test bootstrap does not create a $db global. This contract
* test is unique in that it needs a real database to verify schema
* state, so wire one up here using the same CONFIG_DB_* env vars the
* rest of the CI suite exports. If the database is unavailable, the
* tests below will fail with a clear "no_db_connection" error.
*/
schema_health_check_test_wire_db();
function schema_health_check_test_wire_db(): void
{
if (isset($GLOBALS['db']) && is_object($GLOBALS['db'])) {
return;
}
if (!class_exists('mysqli')) {
return;
}
$host = (string)(getenv('CONFIG_DB_HOST') ?: 'mysql-debug');
$user = (string)(getenv('CONFIG_DB_USER') ?: 'root');
$password = (string)(getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password');
$database = (string)(getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug');
$port = (int)(getenv('CONFIG_DB_PORT') ?: 3306);
try {
mysqli_report(MYSQLI_REPORT_OFF);
$conn = new mysqli($host, $user, $password, $database, $port);
if ($conn->connect_errno) {
return;
}
$conn->set_charset('utf8mb4');
} catch (\Throwable $e) {
return;
}
$GLOBALS['db'] = new class($conn) {
private mysqli $conn;
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function query(string $sql)
{
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result ? $result->fetch_assoc() : null;
}
public function close(): void
{
try {
$this->conn->close();
} catch (\Throwable) {
}
}
};
}
/**
* The unit suite starts with a freshly-dropped `nnks_db_debug` database
* (see run_ci_suite::reset_ci_state). The schema bootstrap only owns
* the `users` table; `invoices` and `bookings` are managed by other
* migrations that don't run in the unit suite. Create the bare-minimum
* schema that adminRoute::runSchemaCheck needs so the third test can
* verify the "all columns exist" happy path.
*/
function schema_health_check_test_ensure_aux_tables(): void
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$create = function (string $table, string $createSql, array $requiredColumns) use ($db): void {
$r = $db->query("SHOW TABLES LIKE '{$table}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query($createSql);
return;
}
foreach ($requiredColumns as $column => $definition) {
$r = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
};
$create('invoices', "CREATE TABLE `invoices` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
po_number VARCHAR(64) NULL,
closed_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'po_number' => 'VARCHAR(64) NULL',
'closed_at' => 'DATETIME NULL',
'customer_number' => 'INT NOT NULL DEFAULT 0',
]);
$create('bookings', "CREATE TABLE `bookings` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
department INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'customer_number' => 'INT NOT NULL DEFAULT 0',
'department' => 'INT NULL',
]);
}
beforeEach(function () {
// Force a fresh real DB connection. Earlier unit tests in the
// same process may have left $GLOBALS['db'] as a Mockery mock,
// which would cause the schema bootstrap below to silently no-op
// and leave the `users` table uncreated. The wiring helper
// short-circuits when a $db is already set, so we unset first.
unset($GLOBALS['db']);
schema_health_check_test_wire_db();
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
// Reset the bootstrap's static `$initialized` cache. A
// previous test (possibly against a mock $db) may have set
// it to true, which would cause ensureSchema() to skip
// creating the `users` table on our real connection.
$bootstrapRef = new ReflectionClass(customer_invoice_email_schema_bootstrap::class);
$initProp = $bootstrapRef->getProperty('initialized');
$initProp->setAccessible(true);
$initProp->setValue(null, false);
// Self-heal: run the schema bootstrap so the test DB has all
// the columns the contract requires. The bootstrap is additive
// and idempotent — safe to run on every test.
customer_invoice_email_schema_bootstrap::ensureSchema();
}
schema_health_check_test_ensure_aux_tables();
});
it('users table has every required column the code references', function () {
global $db;
expect($db)->toBeObject();
expect(method_exists($db, 'query'))->toBeTrue();
$missing = [];
foreach (REQUIRED_USERS_COLUMNS as $column) {
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `users` LIKE '{$safeColumn}'");
if (!$result || (int)$result->num_rows === 0) {
$missing[] = $column;
}
}
expect($missing)->toBe(
[],
"users table is missing required columns: " . implode(', ', $missing)
. ". Did the migration run? See customer_invoice_email_schema_bootstrap."
);
});
it('invoice_email column accepts a normal email address', function () {
global $db;
// Insert a throwaway user with an invoice_email, read it back.
// If the column doesn't exist or the type is wrong, this fails.
$email = 'test-invoice-' . uniqid() . '@example.com';
$customerNumber = 99900000 + random_int(1, 99999);
$db->query("INSERT INTO `users` (customer_number, display_name, email, invoice_email) VALUES ({$customerNumber}, 'Contract Test', '{$email}', 'invoice@example.com')");
$result = $db->query("SELECT invoice_email FROM `users` WHERE customer_number = {$customerNumber}");
expect($result)->toBeObject();
$row = $result->fetch_assoc();
expect($row['invoice_email'] ?? null)->toBe('invoice@example.com');
// Cleanup
$db->query("DELETE FROM `users` WHERE customer_number = {$customerNumber}");
});
it('admin schema-check endpoint reports ok=true when all columns exist', function () {
$admin = new \routes\adminRoute();
$reflection = new ReflectionClass($admin);
$method = $reflection->getMethod('runSchemaCheck');
$method->setAccessible(true);
$report = $method->invoke($admin);
expect($report['ok'])->toBeTrue(
'schema check failed: ' . json_encode($report['missing'] ?? [])
);
expect($report['columns_checked'])->toBeGreaterThan(0);
});