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 1247 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
+8
View File
@@ -1,5 +1,13 @@
# 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
+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);
});