Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 |
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,6 +47,7 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
|
||||
+10
-1
@@ -128,7 +128,16 @@ tar \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||
|
||||
@@ -425,7 +425,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||
{
|
||||
$session = $reg !== null
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
@@ -438,7 +438,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
if (!$session->markCompletedIfOpen($orderId)) {
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
if ($disableRelays) {
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
}
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
|
||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
||||
|
||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
||||
|
||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ Public methods:
|
||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
||||
|
||||
Key implementation details:
|
||||
|
||||
|
||||
@@ -538,7 +538,8 @@ trait selfserve_lane_command_t
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Session completion must not block STOP flow.
|
||||
|
||||
@@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const TOKEN_LENGTH = 32;
|
||||
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$this->setTable('customer_password_reset_keys');
|
||||
}
|
||||
|
||||
private function validTokenWhereClause(): string
|
||||
{
|
||||
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add a new customer reset key
|
||||
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
|
||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
// Query the database for a valid token
|
||||
$current_time = date('Y-m-d H:i:s');
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
|
||||
// Query the database for a valid token using the same clock that writes created_at.
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return null;
|
||||
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
|
||||
*/
|
||||
public function isValidToken(): bool
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$created_at = strtotime($this->created_at->value());
|
||||
$current_time = time();
|
||||
return (
|
||||
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||
($this->deleted_at->value() === null
|
||||
);
|
||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
return $result->num_rows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,8 @@ class authRoute
|
||||
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
||||
|
||||
$subject = 'Adgangskode nulstilling';
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
||||
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
|
||||
|
||||
try {
|
||||
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use objects\customer_password_reset_keys_o;
|
||||
|
||||
app_require('objects/customer_password_reset_keys_o.php');
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
|
||||
class PasswordResetTokenExpiryFakeResult
|
||||
{
|
||||
public int $num_rows;
|
||||
|
||||
public function __construct(private readonly array $rows)
|
||||
{
|
||||
$this->num_rows = count($rows);
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return $this->rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryFakeDb')) {
|
||||
class PasswordResetTokenExpiryFakeDb
|
||||
{
|
||||
public array $queries = [];
|
||||
|
||||
public function __construct(private readonly array $results)
|
||||
{
|
||||
}
|
||||
|
||||
public function escape_string(string $string): string
|
||||
{
|
||||
return addslashes($string);
|
||||
}
|
||||
|
||||
public function query(string $sql): PasswordResetTokenExpiryFakeResult
|
||||
{
|
||||
$this->queries[] = $sql;
|
||||
|
||||
return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
||||
class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o
|
||||
{
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function forceSelectedId(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
it('keeps password reset tokens valid for 72 hours', function (): void {
|
||||
expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60);
|
||||
});
|
||||
|
||||
it('looks up reset tokens using the database 72 hour validity window', function (): void {
|
||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||
]);
|
||||
|
||||
$token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH);
|
||||
$probe = new PasswordResetTokenExpiryProbe();
|
||||
|
||||
$found = $probe->findValidByToken($token);
|
||||
|
||||
expect($found)->toBe($probe)
|
||||
->and($probe->id)->toBe(42)
|
||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)')
|
||||
->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('");
|
||||
});
|
||||
|
||||
it('uses the same database 72 hour window for the selected token guard', function (): void {
|
||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
||||
]);
|
||||
|
||||
$probe = new PasswordResetTokenExpiryProbe();
|
||||
$probe->forceSelectedId(42);
|
||||
|
||||
expect($probe->isValidToken())->toBeTrue()
|
||||
->and($GLOBALS['db']->queries[0])->toContain('id = 42')
|
||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
function phpFpmWorkerConfigRepoRoot(): string
|
||||
{
|
||||
$configuredRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
|
||||
if (is_string($configuredRoot) && $configuredRoot !== '') {
|
||||
return rtrim($configuredRoot, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
$appRoot = defined('WD') ? WD : dirname(__DIR__, 3);
|
||||
|
||||
return dirname($appRoot, 3);
|
||||
}
|
||||
|
||||
function phpFpmWorkerConfigRepoPath(string $relative): string
|
||||
{
|
||||
return phpFpmWorkerConfigRepoRoot() . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative);
|
||||
}
|
||||
|
||||
function phpFpmWorkerConfigValue(string $poolConfig, string $key): ?int
|
||||
{
|
||||
if (!preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\d+)\s*$/m', $poolConfig, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
it('configures PHP-FPM with multiple warm request workers', function (): void {
|
||||
$poolPath = phpFpmWorkerConfigRepoPath('services/php/php-fpm-pool.conf');
|
||||
expect(is_file($poolPath))->toBeTrue();
|
||||
|
||||
$poolConfig = (string)file_get_contents($poolPath);
|
||||
|
||||
expect($poolConfig)->toContain('[www]')
|
||||
->and($poolConfig)->toContain('pm = dynamic')
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_children'))->toBeGreaterThanOrEqual(8)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.start_servers'))->toBeGreaterThanOrEqual(4)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.min_spare_servers'))->toBeGreaterThanOrEqual(4)
|
||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_spare_servers'))->toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('copies the worker pool config into every API PHP image', function (): void {
|
||||
$copyInstruction = 'COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf';
|
||||
$dockerfiles = [
|
||||
phpFpmWorkerConfigRepoPath('Dockerfile'),
|
||||
phpFpmWorkerConfigRepoPath('Dockerfile.coolify-api'),
|
||||
phpFpmWorkerConfigRepoPath('services/php/Dockerfile'),
|
||||
];
|
||||
|
||||
foreach ($dockerfiles as $dockerfile) {
|
||||
expect(is_file($dockerfile))->toBeTrue();
|
||||
expect((string)file_get_contents($dockerfile))->toContain($copyInstruction);
|
||||
}
|
||||
});
|
||||
@@ -79,6 +79,8 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
|
||||
|
||||
expect($washFlow)->not->toBeFalse();
|
||||
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
||||
expect($washFlow)->toContain('bool $disableRelays = true');
|
||||
expect($washFlow)->toContain('if ($disableRelays) {');
|
||||
|
||||
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
@@ -108,3 +110,22 @@ it('always dispatches completion relay off for configured machine relays without
|
||||
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normal STOP completion skips duplicate completion relay cleanup after STOP already disabled relays', function (): void {
|
||||
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
|
||||
|
||||
expect($commandTrait)->not->toBeFalse();
|
||||
$methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(): void');
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
|
||||
$methodBody = substr($commandTrait, (int)$methodOffset, 1500);
|
||||
expect($methodBody)->toContain(<<<'PHP'
|
||||
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
PHP);
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ WORKDIR /var/www/html
|
||||
|
||||
# Copy and enable entrypoint that installs Composer deps on first run
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[www]
|
||||
pm = dynamic
|
||||
pm.max_children = 8
|
||||
pm.start_servers = 4
|
||||
pm.min_spare_servers = 4
|
||||
pm.max_spare_servers = 8
|
||||
pm.max_requests = 500
|
||||
Reference in New Issue
Block a user