Add development guidelines, testing rules, and secure routes documentation

- Add `.junie/guidelines.md` with comprehensive development instructions.
- Include `.aiassistant/rules/Creating and maintaining tests.md` and `.aiassistant/rules/Creating and securing routes.md`.
- Introduce `CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES` for lane service validation.
- Update `selfserve_lane_relay_controller_t` to enforce service-specific permissions for machine relay.
This commit is contained in:
Jeppe Bundgaard
2026-02-18 14:13:05 +01:00
parent 008e2af09a
commit 51014bf774
8 changed files with 800 additions and 0 deletions
@@ -0,0 +1,262 @@
---
apply: always
---
### Creating and maintaining tests — Rules (Projectspecific)
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
1. Scope and philosophy
- Prefer fast, deterministic CLI scripts over frameworkbased tests.
- Keep unitstyle tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
2. Location and naming
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
3. Execution modes
- Unitstyle (preferred): run on host with PHP CLI.
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Integrationstyle (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
4. Bootstrapping and includes
- At the top of every test, define `WD` if not already defined:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
- For unitstyle tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
- Do NOT include `config.php` for unitstyle tests; it throws if `USE_ENV` is not set.
- For integrationstyle tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
5. Output and exit codes
- Use simple helpers for humanreadable output:
```php
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
```
- Prefer collecting failures and exiting nonzero when any assertion fails:
```php
$failed = 0;
// ... on failure set $failed = 1;
exit($failed);
```
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
6. Determinism, time, and randomness
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
7. External I/O and side effects
- Never call external services (Stripe, economic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
8. Redis and database (integrationonly)
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
9. Performance budgets
- Each unitstyle test script should complete in < 100 ms on a typical dev machine.
- Each integrationstyle test should complete in < 2 s and must avoid N+1 loops or heavy queries.
10. When to add or update tests
- New route/feature: add at least one unitstyle test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
- Refactor: keep behaviorpreserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
11. Maintenance expectations
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
- Match the project code style and line endings (see `.editorconfig`: UTF8, CRLF, 4space indent, class brace `next_line`).
12. Templates
- Unitstyle template:
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
// require_once WD . '/modules/foo/classes/bar.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Arrange / Act
$sum = 2 + 2;
// Assert
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
echo "\nExampleUnitTest completed.\n";
exit($failed);
```
- Integrationstyle template (Redis example):
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
require_once WD . '/classes/redis.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Use a namespaced test key
$key = 'test:redis:example:' . uniqid('', true);
try {
$r = new classes\redis();
$r->set($key, 'pong');
$val = $r->get($key);
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
} finally {
// Besteffort cleanup
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
}
echo "\nExampleRedisIntegrationTest completed.\n";
exit($failed);
```
13. Quick reference
- Run unitstyle test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Run integrationstyle test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
---
apply: always
---
### Creating and maintaining tests — Rules (Projectspecific)
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
1. Scope and philosophy
- Prefer fast, deterministic CLI scripts over frameworkbased tests.
- Keep unitstyle tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
2. Location and naming
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
3. Execution modes
- Unitstyle (preferred): run on host with PHP CLI.
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Integrationstyle (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
4. Bootstrapping and includes
- At the top of every test, define `WD` if not already defined:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
- For unitstyle tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
- Do NOT include `config.php` for unitstyle tests; it throws if `USE_ENV` is not set.
- For integrationstyle tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
5. Output and exit codes
- Use simple helpers for humanreadable output:
```php
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
```
- Prefer collecting failures and exiting nonzero when any assertion fails:
```php
$failed = 0;
// ... on failure set $failed = 1;
exit($failed);
```
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
6. Determinism, time, and randomness
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
7. External I/O and side effects
- Never call external services (Stripe, economic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
8. Redis and database (integrationonly)
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
9. Performance budgets
- Each unitstyle test script should complete in < 100 ms on a typical dev machine.
- Each integrationstyle test should complete in < 2 s and must avoid N+1 loops or heavy queries.
10. When to add or update tests
- New route/feature: add at least one unitstyle test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
- Refactor: keep behaviorpreserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
11. Maintenance expectations
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
- Match the project code style and line endings (see `.editorconfig`: UTF8, CRLF, 4space indent, class brace `next_line`).
12. Templates
- Unitstyle template:
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
// require_once WD . '/modules/foo/classes/bar.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Arrange / Act
$sum = 2 + 2;
// Assert
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
echo "\nExampleUnitTest completed.\n";
exit($failed);
```
- Integrationstyle template (Redis example):
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
require_once WD . '/classes/redis.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Use a namespaced test key
$key = 'test:redis:example:' . uniqid('', true);
try {
$r = new classes\redis();
$r->set($key, 'pong');
$val = $r->get($key);
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
} finally {
// Besteffort cleanup
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
}
echo "\nExampleRedisIntegrationTest completed.\n";
exit($failed);
```
13. Quick reference
- Run unitstyle test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Run integrationstyle test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
@@ -0,0 +1,173 @@
---
apply: always
---
### Creating and securing routes — Rules (Projectspecific)
These rules define how to add HTTP routes to the Copenhagen Truck Wash API and how to secure them consistently. They reflect current patterns in `services/nginx/app/routes` and the helper APIs in `traits\route_t`.
1. Location, naming, and structure
- Place route classes under `services/nginx/app/routes`.
- File names should describe the domain and end with `Route.php`, e.g., `BookingsRoute.php`, `ModuleSelfServeRoute.php`.
- Namespace must be `routes` and each file must define one class that uses `traits\route_t` and implements `run(): void`.
```php
<?php
namespace routes;
use traits\route_t;
class ExampleRoute
{
use route_t;
public function run(): void
{
// endpoints go here
}
}
```
2. Registering endpoints (HTTP verbs)
- Define endpoints inside `run()` using the helpers provided by `route_t`:
- `get($path, $handler, $permissions = [])`
- `post($path, $handler, $permissions = [])`
- `put($path, $handler, $permissions = [])`
- `delete($path, $handler, $permissions = [])`
- `patch($path, $handler, $permissions = [])`
- `options($path, $handler, $permissions = [])` (useful for CORS preflight when needed)
- Handlers are closures that perform validation, authorization, side effects, and write responses via the global `$response` (`classes\response`).
- Example (readonly endpoint):
```php
$this->get('/example', function () {
global $response;
$response->success(['message' => 'Hello World!']);
});
```
3. Route paths and local routing
- Caddy serves the app directly; Traefik adds an external `/api` prefix for local access.
- Direct path in the app: `/foo/bar`.
- Local access paths (both work):
- `http://localhost/foo/bar` (direct)
- `http://localhost/api/foo/bar` (Traefik stripprefix `/api` → still routes to `/foo/bar` in the app)
- Keep paths meaningful and resourceoriented. Prefer plural nouns for collections and subpaths for actions when unavoidable (e.g., `/modules/self-serve/lane/status`).
4. Responses and status codes
- Use `$response->success($payload, $status = 200)` for successful results and `$response->error($messageOrPayload, $status)` for failures.
- Typical statuses:
- 200 OK for successful reads/updates; 201 Created when new resources are created.
- 400 Bad Request for validation errors.
- 401 Unauthorized when authentication is missing/invalid.
- 403 Forbidden when authenticated but lacking permissions.
- Return concise, nonsensitive error messages. Internal details go to logs (see `objects\logs_o`).
5. Input handling and validation (route_t helpers)
- Read parameters via:
- `getParameter($name)` or `getParametersAsArray()` (preferred; integrates with `classes\response` parsing)
- `fromRequest($name)` (checks JSON body, then `$_POST`, then query)
- `fromQuery($name)` and `fromRoute($segmentName)` when relevant
- Validate early and fail fast using helpers:
- Presence: `requireParameters(['a','b'])`, `isParametersSet(['a','b'])`
- Types: `requireType($val, type_string()|type_int()|TYPE_BOOL()|TYPE_ARRAY())`, `requireTypeIn($val, [...])`
- Ranges/lengths: `requireMinValue($n, $min)`, `requireMaxValue($n, $max)`, `requireMaxLength($name, $len)`, `requireParameterIntPositive($n, $name)`
- Formats/enums: `requireDateFormat($date, FORMAT_DATE())`, `requireInArray($val, $allowed)`
6. Authentication and authorization (core rules)
- Default posture: endpoints are protected and require authentication unless explicitly public.
- To check authentication without a specific permission: `isAuthenticated()`.
- Enforce permissions inside handlers using:
- `requirePermission('some_permission')` — throws 401/403 as appropriate.
- `hasPermission('some_permission', $customerNumber = null)` — boolean check (do not throw).
- Register permissions for discoverability/administration by supplying the third `$permissions` argument when declaring the route. Keys are permission names; values are humanreadable descriptions. You should still call `requirePermission()` inside the handler to actually enforce.
```php
$this->get('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_view');
// ...
$response->success(['status' => 'OK']);
}, [
'modules_selfserve_lane_status_view' => 'View self-serve lane status',
]);
```
7. Subusers, permission nodes, and customer context
- When a permission corresponds to a subuser permission node, define it with `definePermission($perm, subusers_permission_node_key::CASE)` to keep mapping explicit.
- Subuser contexts may target a specific customer via the `X-Customer-Number` header or a `customer_number` parameter; `route_t` exposes this as response meta (`target_customer_number`) when applicable.
- Use `allowOwnOrDepartmentAccess($ownPerm, $deptPerm, $targetCustomerNumber, $departmentId, $ownGuard = null, $denyMessage = null)` for the common pattern: allow the principal to act on their own customer scope or fall back to a department/admin permission.
- Use `isOwnCustomerContext($targetCustomerNumber)` and `resolveEffectiveCustomerNumber()` when building conditional logic.
8. Department, order, and special access helpers
- Department access: `requireDepartmentAccess($departmentId, $permissionSuffix = null)`, `hasDepartmentAccess(...)`.
- Order access: `requireOrderAccess($orderId, $permissionSuffix = null)` (note: indirect access rules may evolve; keep logic minimal in routes).
- Plate scanners (API key based): protect endpoints with `requirePlateScannerAuth()`.
- Bot protection (public forms): use `requireRecaptcha()` and expect `g_recaptcha_response` in the request.
9. Public endpoints
- Keep public endpoints extremely limited. If an endpoint must be public, you must:
- Validate all inputs thoroughly using the helpers above.
- Add ratelimit or abusemitigation where relevant (Redis is available as `classes\redis` for counters/locks; coordinate design with maintainers before introducing new limits).
- Prefer `requireRecaptcha()` for anonymous form submissions.
10. Side effects and idempotency
- For POST/PUT/PATCH with side effects, make operations idempotent when feasible (e.g., by honoring a client idempotency key header). If you add such behavior, document it in the routes docblock and in `openapi.yaml`.
- Wrap multistep operations with proper validation and permission checks before any external calls (Stripe, economic, etc.). Do not leak thirdparty error payloads directly; map to concise API errors and log details.
11. Logging and observability
- On auth/permission denials, the helpers already log to `logs_o` with structured messages. Avoid duplicating logs for the same event.
- For unexpected states you handle gracefully, add targeted logs via `objects\logs_o` with a clear category/key.
12. OpenAPI contract synchronization
- Every public surface change must be reflected in `openapi.yaml` at repo root. Keep paths, methods, parameters, request/response schemas, and error statuses up to date.
- Use the same path strings as in the route declaration (remember Traefiks `/api` is stripped before hitting the app).
- If you add or change permissions that users must hold, document them in the endpoint description.
13. Example: secure command endpoint
```php
<?php
namespace routes;
use traits\route_t;
use classes\authentication;
class LaneCommandRoute
{
use route_t;
public function run(): void
{
$this->post('/modules/self-serve/lane/command', function () {
global $response;
// AuthZ
self::requirePermission('modules_selfserve_lane_command_execute');
// Validate input
self::requireParameters(['lane_id', 'command']);
$laneId = (int) self::getParameter('lane_id');
self::requireType($laneId, self::type_int());
self::requireMinValue($laneId, 1);
$cmd = (string) self::getParameter('command');
self::requireType($cmd, self::type_string());
self::requireInArray($cmd, ['START','STOP','RESERVE','RELEASE','RESET']);
// Business logic (call into modules/classes)
// ...
$response->success(['ok' => true]);
}, [
'modules_selfserve_lane_command_execute' => 'Execute selfserve lane commands',
]);
}
}
```
14. Quick reference (local)
- Bring up minimal stack: `docker compose up -d traefik redis php1 caddy`
- Access an app route locally:
- `http://localhost/<path>` or `http://localhost/api/<path>`
- Tests are CLI scripts under `services/nginx/app/tests`. Prefer unitstyle tests for core logic; see “Creating and maintaining tests — Rules”.
15. Code style and hygiene
- Follow `.editorconfig` (UTF8, CRLF, 4space indents).
- Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200300 lines or mixes domains, split it.
- Do not perform heavy bootstrap in route files. Delegate to classes/modules and keep handlers thin: validate → authorize → invoke → respond.