Compare commits

..
Author SHA1 Message Date
bugfix-subagent c9c08dea56 test(api): wire up real mysqli in SchemaHealthCheckTest (TRU-77 unit CI)
The Unit suite starts with a freshly-dropped test database and does
not create a $db global, so:

- customer_invoice_email_schema_bootstrap::ensureSchema() short-
  circuited and never created the users table.
- runSchemaCheck had nothing to inspect and reported ok=false.

Wire up a real mysqli connection at file load time using the same
CONFIG_DB_* env vars the rest of the CI suite exports, then have
beforeEach run the bootstrap and create the bare-minimum invoices /
bookings tables that runSchemaCheck verifies. This keeps the test
self-contained inside the Unit suite without changing the
production code or the production schema.

Refs: api#383, TRU-77
2026-08-16 19:01:46 +00:00
Jeppe B 9e18f8988b feat(api): schema health check + pre-deploy migration runner
Closes the 'Unknown column invoice_email in SELECT' production failure
mode (TRU-77) by:

- New GET /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
2026-08-16 18:48:38 +00:00
17 changed files with 1130 additions and 1798 deletions
-115
View File
@@ -1,115 +0,0 @@
# 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
-16
View File
@@ -1,16 +0,0 @@
# Security documentation
This folder holds security-related planning, post-mortems, and pen-test
artefacts for the Truck Wash ApS platform.
| Doc | Purpose | Status |
| --- | --- | --- |
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
Conventions:
- Pen-test reports and any raw findings live in date-stamped subfolders
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
repository — only the planning docs and re-test acceptance letters are.
- All security work is tracked under the Linear project
*UI Library & Pen Testing*.
-304
View File
@@ -1,304 +0,0 @@
# 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.*
@@ -1,111 +0,0 @@
<?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
@@ -1,82 +0,0 @@
<?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.
}
}
}
}
+3 -65
View File
@@ -33,17 +33,17 @@ class slack implements notification_i
public function send_department_booking_notification(int $department_id, $message): self
{
// Get the departments webhook
$webhook = static::get_department_webhook($department_id);
$webhook = self::get_department_webhook($department_id);
// Check if the webhook is empty
if (empty($webhook)) {
throw new \Exception('Department webhook is empty');
}
// Send the notification to the department
self::add_log(static::send_webhook_message($message, $webhook));
self::add_log(self::send_webhook_message($message, $webhook));
return $this;
}
protected function get_department_webhook(int $department_id): string|null
private function get_department_webhook(int $department_id): string|null
{
// Check if the department webhook is cached
$webhook = redis->get_department_webhook($department_id);
@@ -134,68 +134,6 @@ class slack implements notification_i
. "Status: $status";
}
/**
* Send a new-booking notification to the department's Slack webhook.
*
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
* bookings (pickup_bool === false) are intentionally silenced per
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
* channel. Other delivery channels (SMS, email) are unaffected.
*
* Returns true if a Slack message was sent, false if it was filtered
* out (drop-off) or the department has no Slack webhook configured.
*
* @throws \Exception If the department lookup or webhook send fails.
*/
public function send_new_booking_notification(
$id,
$customer_number,
string $wash_type,
string $contact_email,
string $reference_number,
string $regNrTraekker,
string $regNrTrailer,
string $washCertificateEmail,
string $date,
int $department,
bool $pickup_bool,
string $notes,
string $washCertificateStatus,
string $washCertificateUrl,
string $status
): bool {
// TRU-106: drop-off bookings must not post to Slack.
if (!$pickup_bool) {
return false;
}
$webhook = static::get_department_webhook($department);
if (empty($webhook)) {
return false;
}
$message = static::format_new_booking(
$id,
$customer_number,
$wash_type,
$contact_email,
$reference_number,
$regNrTraekker,
$regNrTrailer,
$washCertificateEmail,
$date,
$department,
$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
);
self::add_log(static::send_webhook_message($message, $webhook));
return true;
}
public function send_message(string $string, ?string $module = null): void
{
global $SLACK_DEFAULT_WEBHOOK;
-12
View File
@@ -213,18 +213,6 @@ 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) ?: '');
@@ -42,13 +42,6 @@ class economic_invoice_draft
*/
protected float $conversion_rate = 1.0;
/**
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
* Defense in depth — even after sanitization, a final check catches anything that slips through.
* @var bool $preflight_enabled
*/
protected bool $preflight_enabled = true;
/**
* Construct a new Economic draft invoice object
@@ -123,142 +116,9 @@ class economic_invoice_draft
*/
public function addLines(): void
{
if ($this->preflight_enabled) {
$this->preflightValidate($this->draft_lines, null);
}
$this->flushLinesInBatches();
}
/**
* Pre-flight validation: defense in depth before sending to e-conomic.
* Validates each line against 5 rules and throws RuntimeException on the first violation.
*
* Rules (in order, per line):
* 1. description — must be non-empty after trim()
* 2. description — must be <= 250 chars
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
* 4. quantity — must be a positive number (> 0)
* 5. unitNetPrice — must be a number (>= 0)
*
* @param array<int,array<string,mixed>> $lines
* @param int|null $orderId Optional order id for log context
* @throws \RuntimeException on any rule violation
*/
public function preflightValidate(array $lines, ?int $orderId = null): void
{
foreach ($lines as $i => $line) {
if (!is_array($line)) {
$this->logAndThrow(
$i,
$orderId,
'line is not an array',
$line
);
}
// Rule 1 + 2: description
$description = $line['description'] ?? null;
if ($description === null) {
$description = '';
}
if (!is_scalar($description)) {
$description = (string)$description;
} else {
$description = (string)$description;
}
$descriptionTrimmed = trim($description);
if ($descriptionTrimmed === '') {
$this->logAndThrow(
$i,
$orderId,
'description is empty',
$description
);
}
if (mb_strlen($descriptionTrimmed) > 250) {
$this->logAndThrow(
$i,
$orderId,
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
$description
);
}
// Rule 3: productNumber (only if present in product.productNumber)
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
$productNumber = $line['product']['productNumber'];
if ($productNumber === null) {
$productNumber = '';
} else {
$productNumber = (string)$productNumber;
}
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
$this->logAndThrow(
$i,
$orderId,
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
$productNumber
);
}
}
// Rule 4: quantity — only required if present in the line (text lines omit it)
if (array_key_exists('quantity', $line)) {
$quantity = $line['quantity'];
if (!is_numeric($quantity) || (float)$quantity <= 0) {
$this->logAndThrow(
$i,
$orderId,
'quantity is not a positive number',
$quantity
);
}
}
// Rule 5: unitNetPrice — only required if present in the line
if (array_key_exists('unitNetPrice', $line)) {
$unitNetPrice = $line['unitNetPrice'];
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
$this->logAndThrow(
$i,
$orderId,
'unitNetPrice is not a number >= 0',
$unitNetPrice
);
}
}
}
}
/**
* Log the offending line and throw a RuntimeException.
*/
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
{
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
if ($valueTruncated === false) {
$valueTruncated = '[unserializable]';
}
if (mb_strlen($valueTruncated) > 200) {
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
}
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
error_log(sprintf(
'[preflight] validation failed: %s | line=%d | %s | value=%s',
$rule,
$lineIndex,
$orderContext,
$valueTruncated
));
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
throw new \RuntimeException(sprintf(
'Preflight validation failed for line %d: %s%s',
$lineIndex,
$rule,
$orderPart
));
}
/**
* Add queued draft lines using chunked requests.
*
@@ -325,55 +185,45 @@ 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: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
self::addTextLine('PO: ' . $order->po->value());
}
// If there's a reference, add it to the invoice
$reference_value = $order->reference->value();
if ($reference_value !== '') {
if ($order->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($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $reference_sanitized);
self::addTextLine('# ' . $order->reference->value());
}
}
// Add the registration numbers (if any)
$line_reg = '';
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));
}
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());
// 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
$notes_value = $order->notes->value();
if ($notes_value !== '') {
if ($order->notes->value() !== '') {
self::addTextLine('Notat:');
// 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 ) {
// 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 ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $notes_sanitized);
self::addTextLine('# ' . $order->notes->value());
}
}
}
@@ -491,28 +341,26 @@ class economic_invoice_draft
// If there's a reference, add it to the line
if ($order_item['reference'] !== '') {
self::addTextLine('Reference:');
// 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 ) {
// 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 ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $item_reference_sanitized);
self::addTextLine('# ' . $order_item['reference']);
}
}
// If there's a note, add it to the line
if (!empty($order_item['notes'])) {
self::addTextLine('Notat:');
// 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 ) {
// 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 ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $item_notes_sanitized);
self::addTextLine('# ' . $order_item['notes']);
}
}
@@ -622,9 +470,6 @@ 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 = [
+6 -10
View File
@@ -205,12 +205,10 @@ class bookings_o extends db
$sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql);
if ($db->num_rows($result) === 0) {
// Send a department webhook if the booking is new.
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = 0) so only pickup bookings post to Slack.
// Send a department webhook if the booking is new
$slack = new slack();
try {
$slack->send_new_booking_notification(
$slack->send_department_booking_notification($department, $slack->format_new_booking(
$id,
$customer_number,
$wash_type,
@@ -221,12 +219,12 @@ class bookings_o extends db
$washCertificateEmail,
$date,
$department,
(bool)$pickup_bool,
$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
);
));
} catch (Exception $e) {
// Log the error
$logs = new logs_o();
@@ -315,13 +313,11 @@ class bookings_o extends db
!$deliverSlack // Only send email if slack is not available
);
// Check if the department has a slack webhook
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = false) so only pickup bookings post to Slack.
if ($deliverSlack) {
// Send a notification to the department
$slack = new slack();
try {
$slack->send_new_booking_notification(
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
$this->id,
$customer_array['customer_number'],
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
@@ -337,7 +333,7 @@ class bookings_o extends db
$this->washCertificateStatus->value(),
$this->washCertificateUrl->value(),
$this->status->value()
);
));
} catch (Exception $e) {
// Previously this bare call would crash the entire
// notifyNewBooking() flow if Slack returned non-2xx, so
@@ -1,14 +1,9 @@
<?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, scheduler, CLI, and routes', function (): void {
it('wires cron workers through schema, CLI, scheduler, and superuser 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');
@@ -16,6 +11,12 @@ it('wires cron workers through schema, scheduler, CLI, and routes', function ():
$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');
@@ -46,24 +47,29 @@ it('wires cron workers through schema, scheduler, CLI, and routes', function ():
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');
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',
];
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');
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();
}
});
@@ -98,18 +104,3 @@ 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");
});
@@ -1,224 +0,0 @@
<?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')
);
}
}
@@ -1,379 +0,0 @@
<?php
namespace tests\Unit\Economic;
use helpers\economic_invoice_draft;
use PHPUnit\Framework\TestCase;
use RuntimeException;
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
/**
* Unit tests for the pre-flight validation in economic_invoice_draft.
*
* These tests build a draft instance via the skip_fetch path (so we never
* hit the e-conomic API), call preflightValidate() directly, and assert
* that each rule is enforced.
*/
class EconomicInvoiceDraftPreflightTest extends TestCase
{
/**
* Build a draft instance in skip_fetch mode. We never hit the network.
*/
private function makeDraft(bool $preflight = true): economic_invoice_draft
{
$draft = new economic_invoice_draft(12345, 'DKK', true);
// Make the preflight_enabled flag mutable for the disabled test.
$reflection = new \ReflectionClass($draft);
$prop = $reflection->getProperty('preflight_enabled');
$prop->setAccessible(true);
$prop->setValue($draft, $preflight);
return $draft;
}
private function callPreflight(economic_invoice_draft $draft, array $lines, ?int $orderId = null): void
{
$draft->preflightValidate($lines, $orderId);
}
// ========================================================================
// Rule 1: description must be non-empty after trim()
// ========================================================================
public function testEmptyDescriptionThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/description is empty/');
$this->callPreflight($draft, [
['description' => ''],
], 42);
}
public function testWhitespaceOnlyDescriptionThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/description is empty/');
$this->callPreflight($draft, [
['description' => " \t \n "],
], 99);
}
public function testMissingDescriptionThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/description is empty/');
$this->callPreflight($draft, [
['quantity' => 1, 'unitNetPrice' => 10.0],
], 1);
}
// ========================================================================
// Rule 2: description must be <= 250 chars
// ========================================================================
public function testTooLongDescriptionThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/exceeds 250 chars/');
$long = str_repeat('a', 251);
$this->callPreflight($draft, [
['description' => $long],
], 7);
}
public function testDescriptionAt250Passes(): void
{
$draft = $this->makeDraft();
// 250 chars — should pass (not throw)
$this->callPreflight($draft, [
['description' => str_repeat('b', 250)],
], 8);
$this->assertTrue(true); // no exception means success
}
// ========================================================================
// Rule 3: productNumber must match /^[A-Za-z0-9._-]{1,50}$/
// ========================================================================
public function testInvalidProductNumberThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/productNumber does not match/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'PROD/01'],
'quantity' => 1,
'unitNetPrice' => 10.0,
],
], 11);
}
public function testProductNumberTooLongThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/productNumber does not match/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => str_repeat('a', 51)],
'quantity' => 1,
'unitNetPrice' => 10.0,
],
], 12);
}
public function testValidProductNumberPasses(): void
{
$draft = $this->makeDraft();
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'PROD-01.0_v2'],
'quantity' => 1,
'unitNetPrice' => 10.0,
],
], 13);
$this->assertTrue(true);
}
// ========================================================================
// Rule 4: quantity must be a positive number (> 0)
// ========================================================================
public function testZeroQuantityThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 0,
'unitNetPrice' => 10.0,
],
], 21);
}
public function testNegativeQuantityThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => -1.5,
'unitNetPrice' => 10.0,
],
], 22);
}
public function testNonNumericQuantityThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/quantity is not a positive number/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 'NaN-ish',
'unitNetPrice' => 10.0,
],
], 23);
}
// ========================================================================
// Rule 5: unitNetPrice must be a number (>= 0)
// ========================================================================
public function testNegativeUnitPriceThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/unitNetPrice is not a number/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 1,
'unitNetPrice' => -5.0,
],
], 31);
}
public function testNonNumericUnitPriceThrows(): void
{
$draft = $this->makeDraft();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/unitNetPrice is not a number/');
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 1,
'unitNetPrice' => 'free',
],
], 32);
}
public function testZeroUnitPricePasses(): void
{
$draft = $this->makeDraft();
// 0 is allowed — it's "a number >= 0"
$this->callPreflight($draft, [
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 1,
'unitNetPrice' => 0,
],
], 33);
$this->assertTrue(true);
}
// ========================================================================
// Happy path: a fully-valid line passes
// ========================================================================
public function testValidLinePasses(): void
{
$draft = $this->makeDraft();
$this->callPreflight($draft, [
[
'description' => 'A normal product line',
'product' => ['productNumber' => 'WASH-01'],
'quantity' => 2,
'unitNetPrice' => 99.5,
],
[
'description' => 'A text-only line',
],
[
'description' => 'Discount',
'product' => ['productNumber' => 'TotDiscount'],
'quantity' => 1,
'unitNetPrice' => 0.0,
],
], 100);
$this->assertTrue(true);
}
// ========================================================================
// Disabled preflight: bad lines must NOT throw
// ========================================================================
public function testPreflightCanBeDisabled(): void
{
// The preflight_enabled flag gates addLines() (i.e. it controls whether
// preflightValidate() runs before flushLinesInBatches). It does NOT
// affect direct calls to preflightValidate(). So this test verifies:
// 1. The default value is true.
// 2. The flag is mutable to false.
// 3. addLines() is wired to short-circuit preflight when the flag is false.
$draft = $this->makeDraft(true);
// (1) Default: preflight is enabled
$ref = new \ReflectionClass($draft);
$prop = $ref->getProperty('preflight_enabled');
$prop->setAccessible(true);
$this->assertTrue($prop->getValue($draft), 'preflight_enabled should default to true');
// (2) Mutable
$prop->setValue($draft, false);
$this->assertFalse($prop->getValue($draft));
// (3) Disabled: addLines() must NOT call preflightValidate().
// We assert this indirectly: queue a guaranteed-invalid line and then
// catch the exception that flushLinesInBatches() would raise when it
// tries to send to e-conomic. If preflight were enabled we'd get
// RuntimeException("description is empty") first.
$this->expectException(\Throwable::class);
$reflection = new \ReflectionClass($draft);
$linesProp = $reflection->getProperty('draft_lines');
$linesProp->setAccessible(true);
$linesProp->setValue($draft, [
['description' => ''], // would fail rule 1 if preflight ran
]);
$draft->addLines();
}
// ========================================================================
// Multiple errors: report the first one
// ========================================================================
public function testMultipleErrorsReportsFirst(): void
{
$draft = $this->makeDraft();
$caught = null;
try {
$this->callPreflight($draft, [
// First line is fine
[
'description' => 'OK',
'product' => ['productNumber' => 'P1'],
'quantity' => 1,
'unitNetPrice' => 10.0,
],
// Second line: empty description (rule 1)
['description' => ''],
// Third line: would also fail, but we should never get here
[
'description' => 'OK',
'product' => ['productNumber' => 'BAD/CHAR'],
'quantity' => 1,
'unitNetPrice' => 10.0,
],
], 300);
} catch (RuntimeException $e) {
$caught = $e;
}
$this->assertNotNull($caught, 'Expected a RuntimeException to be thrown');
$this->assertStringContainsString('line 1', $caught->getMessage());
$this->assertStringContainsString('description is empty', $caught->getMessage());
$this->assertStringContainsString('order 300', $caught->getMessage());
}
// ========================================================================
// Exception message includes order id when provided
// ========================================================================
public function testExceptionMessageIncludesOrderId(): void
{
$draft = $this->makeDraft();
$caught = null;
try {
$this->callPreflight($draft, [
['description' => ''],
], 4242);
} catch (RuntimeException $e) {
$caught = $e;
}
$this->assertNotNull($caught);
$this->assertStringContainsString('order 4242', $caught->getMessage());
}
public function testExceptionMessageOmitsOrderWhenNull(): void
{
$draft = $this->makeDraft();
$caught = null;
try {
$this->callPreflight($draft, [
['description' => ''],
], null);
} catch (RuntimeException $e) {
$caught = $e;
}
$this->assertNotNull($caught);
$this->assertStringNotContainsString('order', $caught->getMessage());
}
}
@@ -411,6 +411,214 @@ 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');
@@ -1,51 +0,0 @@
<?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);
}
}
@@ -144,27 +144,13 @@ function schema_health_check_test_ensure_aux_tables(): void
}
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();
// 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.
if (!isset($GLOBALS['db']) || !is_object($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();
@@ -1,157 +0,0 @@
<?php
app_require('classes/slack.php');
use classes\slack;
/**
* Fake slack subclass that captures webhook messages without doing I/O.
* Overrides get_department_webhook() so we don't touch redis/db.
*/
final class SlackNewBookingPickupFilterFake extends slack
{
public array $messages = [];
public string $webhook = 'https://hooks.slack.test/services/TRU-106-pickup-filter';
public ?string $webhookOverride = null; // null => use $this->webhook, '' => empty, etc.
public string $sendResult = 'Message sent successfully. Response: ok';
public function __construct()
{
// Skip parent config loading for unit isolation.
}
protected function get_department_webhook(int $department_id): string
{
return $this->webhookOverride ?? $this->webhook;
}
public function send_webhook_message(string $message, string $webhook): string
{
$this->messages[] = [
'message' => $message,
'webhook' => $webhook,
];
return $this->sendResult;
}
/**
* Stub format_new_booking so unit tests don't need a live redis/db
* (the real implementation calls departments_o::getDepartmentName,
* which dereferences the global `redis` object that is not loaded
* in the unit test bootstrap).
*/
public function format_new_booking(
$id,
$customer_number,
string $wash_type,
string $contact_email,
string $reference_number,
string $regNrTraekker,
string $regNrTrailer,
string $washCertificateEmail,
string $date,
int $department,
$pickup_bool,
string $notes,
string $washCertificateStatus,
string $washCertificateUrl,
string $status
): string {
$pickupLabel = $pickup_bool ? '1' : '0';
return "*Ny booking oprettet* ( ID: {$id} )\n"
. "Kunde: ({$customer_number})\n"
. "Type: {$wash_type}\n"
. "Reference nummer: {$reference_number}\n"
. "RegNr Traekker: {$regNrTraekker}\n"
. "RegNr Trailer: {$regNrTrailer}\n"
. "Dato: {$date}\n"
. "Hentning: {$pickupLabel}\n"
. "Noter: {$notes}";
}
}
/**
* Sample booking data used by all the tests below.
*/
function tru106_sample_booking(): array
{
return [
'id' => 4242,
'customer_number' => 1001,
'wash_type' => 'Standard wash',
'contact_email' => 'dispatcher@example.com',
'reference_number' => 'REF-001',
'regNrTraekker' => 'AB12345',
'regNrTrailer' => 'CD67890',
'washCertificateEmail' => '',
'date' => '2026-08-16 09:00:00',
'department' => 4,
'notes' => 'No notes',
'washCertificateStatus' => '',
'washCertificateUrl' => '',
'status' => 'pending',
];
}
function tru106_call_send_new_booking_notification(slack $slack, array $b, bool $pickup): bool
{
return $slack->send_new_booking_notification(
$b['id'],
$b['customer_number'],
$b['wash_type'],
$b['contact_email'],
$b['reference_number'],
$b['regNrTraekker'],
$b['regNrTrailer'],
$b['washCertificateEmail'],
$b['date'],
$b['department'],
$pickup,
$b['notes'],
$b['washCertificateStatus'],
$b['washCertificateUrl'],
$b['status']
);
}
it('posts a Slack notification when the new booking is a pickup (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
expect($sent)->toBeTrue()
->and($slack->messages)->toHaveCount(1)
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/TRU-106-pickup-filter')
->and($slack->messages[0]['message'])->toContain('Ny booking oprettet')
->and($slack->messages[0]['message'])->toContain('ID: 4242')
->and($slack->messages[0]['message'])->toContain('Kunde:')
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully');
});
it('does NOT post a Slack notification when the new booking is a drop-off (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), false);
expect($sent)->toBeFalse()
->and($slack->messages)->toBe([])
->and($slack->get_log())->toBe([]);
});
it('does NOT post a Slack notification when the department has no webhook configured (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$slack->webhookOverride = '';
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
expect($sent)->toBeFalse()
->and($slack->messages)->toBe([])
->and($slack->get_log())->toBe([]);
});
it('does not leak the webhook URL into the log payload for a pickup (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
$logDump = json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES);
expect($logDump)->not->toContain('hooks.slack.test')
->and($logDump)->toContain('sent successfully');
});