Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ec0c2411 | ||
|
|
935b2d58ce | ||
|
|
4c6b60c9f2 | ||
|
|
18092b271e | ||
|
|
76ad696691 | ||
|
|
80dca6b5f0 | ||
|
|
7c4acc636c |
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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*.
|
||||
@@ -0,0 +1,304 @@
|
||||
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
|
||||
|
||||
> ## ⛔ CANCELLED — DO NOT EXECUTE
|
||||
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
|
||||
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
|
||||
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
|
||||
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
|
||||
>
|
||||
> ---
|
||||
|
||||
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
|
||||
**Project:** UI Library & Pen Testing
|
||||
**Priority:** Medium
|
||||
**Status (this doc):** Draft v1 — ready for engineering + management review
|
||||
**Author:** bugfix sub-agent (TRU-80)
|
||||
**Date:** 2026-08-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
|
||||
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
|
||||
is intended to validate the security posture of the customer- and operator-facing
|
||||
production stack before further public rollout and ahead of any major commercial
|
||||
expansion (e.g. additional self-serve sites, additional payment integrations).
|
||||
|
||||
This document is the planning artefact for TRU-80. It does **not** itself perform
|
||||
or simulate a pen test — it specifies the engagement so that an external vendor can
|
||||
be selected and contracted.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope (in)
|
||||
|
||||
The following systems are **in scope** for the engagement. Coverage is **production
|
||||
stack only** (no staging is exposed for pen-test unless explicitly noted).
|
||||
|
||||
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
|
||||
|
||||
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
|
||||
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
|
||||
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
|
||||
- Authentication / session endpoints, including:
|
||||
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
|
||||
`subusersRoute.php`, `limitedBackofficeRoute.php`
|
||||
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
|
||||
- Authorization model: role-based access (customer / sub-user / backoffice /
|
||||
superuser) and per-customer data isolation.
|
||||
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
|
||||
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
|
||||
collected-invoices, order routes.
|
||||
- Payment integration: Stripe module (`moduleStripeRoute.php`).
|
||||
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
|
||||
token handling, invoice push.
|
||||
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
|
||||
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
|
||||
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
|
||||
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
|
||||
Redis trait (`redis_t.php`).
|
||||
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
|
||||
our code consumes it; the upstream WP instance is **out of scope** unless
|
||||
hosted by us.
|
||||
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
|
||||
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
|
||||
`coolify` deploy config. Black-box reachable attack surface only.
|
||||
|
||||
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
|
||||
|
||||
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
|
||||
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
|
||||
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
|
||||
- API client and token storage in the SPA (where tokens live, at-rest
|
||||
protection, refresh flow).
|
||||
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
|
||||
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
|
||||
the root (`openapi.yaml`).
|
||||
- Capacitor deep-link / universal-link / custom-scheme handling
|
||||
(`capacitor.config.ts`).
|
||||
|
||||
### 2.3 Infrastructure & cross-cutting (in)
|
||||
|
||||
- TLS configuration (cert chain, HSTS, cipher suites) on the production
|
||||
public host.
|
||||
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
|
||||
Permissions-Policy, X-Content-Type-Options).
|
||||
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
|
||||
- Email & SMS notification paths only as far as they can be abused for
|
||||
spoofing / phishing of our users (we control the From domain).
|
||||
|
||||
### 2.4 Out of scope (explicitly)
|
||||
|
||||
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
|
||||
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
|
||||
not the third party itself.
|
||||
- Internal office LAN, employee laptops, MDT, and physical site hardware
|
||||
(gate controllers, scanners) — these are covered by a separate physical /
|
||||
OT scope and **out of scope** for this IT pen test.
|
||||
- Denial-of-service / load testing.
|
||||
- Social engineering of Truck Wash staff.
|
||||
- Source-code review of `node_modules` / vendor dependencies (the engagement
|
||||
will use SCA tooling to flag known CVEs, but not audit transitive deps).
|
||||
- Any production data exfiltration — the vendor will be given sanitised or
|
||||
test accounts and synthetic data only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
Industry-standard, manual-led engagement with tooling support. Recommended
|
||||
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
|
||||
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
|
||||
use **OWASP MASVS** as the checklist.
|
||||
|
||||
Phases (estimated total: 12 working days of vendor effort, see §6):
|
||||
|
||||
1. **Scoping & recon (1 day)**
|
||||
- Confirm target list, accounts, and rules of engagement.
|
||||
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
|
||||
- Active recon limited to non-destructive fingerprinting.
|
||||
2. **API pen test (3 days)**
|
||||
- AuthN/AuthZ boundary testing on every route group in §2.1.
|
||||
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
|
||||
wash certificates, sub-users, customer notes).
|
||||
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
|
||||
deserialisation, header injection.
|
||||
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
|
||||
discount stacking, sub-user privilege escalation.
|
||||
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
|
||||
3. **Web SPA pen test (2 days)**
|
||||
- XSS (reflected, stored, DOM-based) including Vue template injection.
|
||||
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
|
||||
- Open-redirect / OAuth misconfig in any SSO flow.
|
||||
- CSP / SRI effectiveness.
|
||||
4. **Mobile (Capacitor) review (2 days)**
|
||||
- Static analysis of the built APK / IPA (Capacitor WebView).
|
||||
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
|
||||
custom-scheme handlers).
|
||||
- Local storage of tokens, biometric bypass if implemented.
|
||||
- Deep-link / universal-link hijack attempts.
|
||||
5. **Infrastructure & config (1.5 days)**
|
||||
- TLS, headers, cookie flags, HSTS preload eligibility.
|
||||
- NGINX hardening review (based on provided config snapshots).
|
||||
- Docker / coolify surface only as externally reachable.
|
||||
6. **SCA / dependency check (0.5 day)**
|
||||
- `composer.json` and `package.json` SCA scan.
|
||||
- High-severity known-CVE report only; no deep audit.
|
||||
7. **Exploitation & PoC (1 day)**
|
||||
- Build proofs-of-concept for any Critical / High findings.
|
||||
8. **Reporting & re-test (1 day)**
|
||||
- Draft report → vendor walkthrough → final report.
|
||||
- Re-test of fixed findings is scoped separately (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 4. Rules of engagement (RoE)
|
||||
|
||||
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
|
||||
exploitation only with prior written approval per critical finding.
|
||||
- **Contact channel:** shared Signal thread + email; vendor given a Slack
|
||||
guest account in a dedicated `#sec-pentest-2026Q4` channel.
|
||||
- **Stop conditions:** any finding that risks data loss, payment integrity,
|
||||
or production gate operation → immediate stop + phone call to on-call.
|
||||
- **Data handling:** vendor may only use synthetic / test data. No
|
||||
exfiltration of real customer PII. All artifacts returned or destroyed at
|
||||
end of engagement (TBD in contract).
|
||||
- **Coverage of third parties:** the vendor will not test Stripe / Economic
|
||||
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
|
||||
we follow responsible-disclosure to the vendor ourselves.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deliverables
|
||||
|
||||
1. **Kick-off doc** (this plan, signed off by both parties).
|
||||
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
|
||||
findings list).
|
||||
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
|
||||
Critical / High so we can start patching in parallel.
|
||||
4. **Final report (PDF + JSON)** including:
|
||||
- Executive summary, risk heatmap, business-impact narrative.
|
||||
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
|
||||
screenshots / Burp session, recommended fix, references.
|
||||
- SCA dependency report as an appendix.
|
||||
5. **Re-test letter** (separate SOW, see §6).
|
||||
6. **Knowledge transfer**: 60-min session for engineering on the top 5
|
||||
findings.
|
||||
|
||||
---
|
||||
|
||||
## 6. Budget & scheduling
|
||||
|
||||
### 6.1 Indicative effort
|
||||
|
||||
| Phase | Days | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1. Scoping & recon | 1.0 | joint with us |
|
||||
| 2. API pen test | 3.0 | |
|
||||
| 3. Web SPA | 2.0 | |
|
||||
| 4. Mobile (Capacitor) | 2.0 | |
|
||||
| 5. Infra & config | 1.5 | |
|
||||
| 6. SCA | 0.5 | tooling-led |
|
||||
| 7. Exploitation / PoC | 1.0 | |
|
||||
| 8. Reporting | 1.0 | incl. 1 review round |
|
||||
| **Total** | **12.0 days** | |
|
||||
|
||||
### 6.2 Indicative cost (DKK, ex. VAT)
|
||||
|
||||
Pricing varies significantly with vendor. Three realistic budget tiers for
|
||||
procurement:
|
||||
|
||||
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 – 16 000 | **144 000 – 192 000** | Best fit for our stack size, Danish-language reporting available. |
|
||||
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 – 22 000 | **180 000 – 264 000** | More brand name, more bureaucracy, stronger report templates. |
|
||||
| Top-tier / Big-4 style | 25 000 – 40 000 | **300 000 – 480 000** | Overkill for current footprint; revisit at Series-A. |
|
||||
|
||||
**Recommended envelope: 180 000 – 220 000 DKK** (mid-tier, 12 days) plus a
|
||||
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
|
||||
report).
|
||||
|
||||
Add ~5 000 DKK contingency for incident-response hours if a Critical is
|
||||
found mid-engagement.
|
||||
|
||||
### 6.3 Schedule (proposed)
|
||||
|
||||
- **2026-08-25** — this plan reviewed and signed off by management.
|
||||
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
|
||||
proposals, evaluate.
|
||||
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
|
||||
- **2026-09-22 (week 39)** — engagement kick-off.
|
||||
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
|
||||
weeks, vendor working in parallel with their normal cadence).
|
||||
- **2026-10-08** — draft report.
|
||||
- **2026-10-15** — final report + walkthrough.
|
||||
- **2026-11-15** — re-test (retainer).
|
||||
|
||||
All dates are **provisional** until a vendor is selected.
|
||||
|
||||
### 6.4 Vendor shortlist (candidates to approach)
|
||||
|
||||
We will request proposals from at least 3 of the following (final shortlist
|
||||
to be confirmed with management):
|
||||
|
||||
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
|
||||
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
|
||||
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
|
||||
for enterprise due-diligence.
|
||||
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
|
||||
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
|
||||
|
||||
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
|
||||
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
|
||||
|
||||
We should land these before the vendor starts — they reduce noise and let
|
||||
the vendor focus on real issues:
|
||||
|
||||
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
|
||||
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
|
||||
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
|
||||
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
|
||||
Gateway webhooks
|
||||
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
|
||||
- [ ] Rate-limit on auth, password reset, and OTP endpoints
|
||||
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
|
||||
- [ ] File-server (`file_server.php`) path-traversal tests in CI
|
||||
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
|
||||
high+ vulns
|
||||
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
|
||||
removed
|
||||
- [ ] Secrets: no production keys in repo (`git log -S` audit)
|
||||
|
||||
This list is also the basis for re-test acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for management
|
||||
|
||||
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
|
||||
2. Confirm legal/procurement owner and contract template.
|
||||
3. Confirm whether to require a Danish-language final report (recommended).
|
||||
4. Confirm re-test budget is approved up-front, or per-finding.
|
||||
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
|
||||
6-day "API + SPA only" first pass.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
|
||||
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
|
||||
- OWASP MASVS — https://mas.owasp.org/MASVS/
|
||||
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
|
||||
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
|
||||
|
||||
---
|
||||
|
||||
*This document is a planning artefact, not the test itself. Once approved, a
|
||||
separate SOW will be drafted with the selected vendor and linked from this
|
||||
issue.*
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = self::get_department_webhook($department_id);
|
||||
$webhook = static::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(self::send_webhook_message($message, $webhook));
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function get_department_webhook(int $department_id): string|null
|
||||
protected function get_department_webhook(int $department_id): string|null
|
||||
{
|
||||
// Check if the department webhook is cached
|
||||
$webhook = redis->get_department_webhook($department_id);
|
||||
@@ -134,6 +134,68 @@ 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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -205,10 +205,12 @@ 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
|
||||
// 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.
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_department_booking_notification($department, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
@@ -219,12 +221,12 @@ class bookings_o extends db
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
(bool)$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
));
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Log the error
|
||||
$logs = new logs_o();
|
||||
@@ -313,11 +315,13 @@ 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_department_booking_notification($department->id, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$this->id,
|
||||
$customer_array['customer_number'],
|
||||
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
|
||||
@@ -333,7 +337,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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
<?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');
|
||||
});
|
||||
Reference in New Issue
Block a user