Refactor subuser permissions and enhance artifact management
This commit is contained in:
File diff suppressed because one or more lines are too long
+44
-3
@@ -6,6 +6,7 @@ use Exception;
|
||||
|
||||
class edge_gateway_agent_artifact_locator
|
||||
{
|
||||
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
|
||||
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
|
||||
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
|
||||
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
|
||||
@@ -28,6 +29,10 @@ class edge_gateway_agent_artifact_locator
|
||||
$candidateDirectories[] = self::normalizePath($configuredDirectory);
|
||||
}
|
||||
|
||||
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
|
||||
|
||||
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
|
||||
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
|
||||
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
|
||||
}
|
||||
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
|
||||
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
|
||||
$candidateDirectories[] = $directory;
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
|
||||
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
|
||||
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function edgeAgentBuildDirectories(string $basePath): array
|
||||
{
|
||||
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories = [
|
||||
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
|
||||
];
|
||||
|
||||
if (DIRECTORY_SEPARATOR === '/') {
|
||||
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
|
||||
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_map(
|
||||
static fn(string $directory): string => self::normalizePath($directory),
|
||||
$directories
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function legacyPhpAgentDirectories(string $basePath): array
|
||||
{
|
||||
return [
|
||||
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function mountedArtifactDirectory(): ?string
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR !== '/') {
|
||||
|
||||
@@ -7,6 +7,7 @@ use Exception;
|
||||
class edge_gateway_install_service
|
||||
{
|
||||
private const ARTIFACTS = [
|
||||
'manifest.json' => 'application/json; charset=utf-8',
|
||||
'agent.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
|
||||
@@ -42,6 +43,10 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function readArtifact(string $fileName): string
|
||||
{
|
||||
if ($fileName === 'manifest.json') {
|
||||
return $this->buildManifest();
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
@@ -66,13 +71,48 @@ class edge_gateway_install_service
|
||||
*/
|
||||
public function artifactPath(string $fileName): string
|
||||
{
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS)) {
|
||||
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
|
||||
throw new Exception('Unknown edge agent artifact');
|
||||
}
|
||||
|
||||
return edge_gateway_agent_artifact_locator::resolve($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildManifest(): string
|
||||
{
|
||||
$artifacts = [];
|
||||
foreach (self::ARTIFACTS as $fileName => $contentType) {
|
||||
if ($fileName === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->artifactPath($fileName);
|
||||
$sha256 = hash_file('sha256', $path);
|
||||
$bytes = filesize($path);
|
||||
if ($sha256 === false || $bytes === false) {
|
||||
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
|
||||
}
|
||||
|
||||
$artifacts[] = [
|
||||
'name' => $fileName,
|
||||
'sha256' => $sha256,
|
||||
'bytes' => $bytes,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'schema_version' => 1,
|
||||
'package' => 'truckwash-edge-agent',
|
||||
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
|
||||
'generated_at' => gmdate('c'),
|
||||
'artifacts' => $artifacts,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return $this->manager ?? new edge_gateway_manager();
|
||||
|
||||
@@ -33,6 +33,7 @@ class edge_gateway_manager
|
||||
public const STATUS_DEGRADED = 'DEGRADED';
|
||||
public const STATUS_OFFLINE = 'OFFLINE';
|
||||
public const DEFAULT_RELEASE_CHANNEL = 'stable';
|
||||
public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3';
|
||||
public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service';
|
||||
public const DEFAULT_STACK_SERVICE_NAME = 'truckwash-edge-gateway-stack.service';
|
||||
public const DEFAULT_COMPOSE_STACK_FILE = 'docker-compose.gateway.yml';
|
||||
@@ -2309,6 +2310,7 @@ class edge_gateway_manager
|
||||
'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
|
||||
'heartbeatIntervalSeconds' => 15,
|
||||
'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
|
||||
'installedVersion' => self::DEFAULT_INSTALL_VERSION,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$script = <<<'BASH'
|
||||
@@ -2543,6 +2545,42 @@ fetch_http() {
|
||||
[ "$cleanup_body" -eq 1 ] && rm -f "$body_path"
|
||||
rm -f "$headers_path"
|
||||
}
|
||||
verify_manifest_artifact() {
|
||||
local manifest_path="$1"
|
||||
local artifact_name="$2"
|
||||
local artifact_path="$3"
|
||||
log_info "Verifying ${artifact_name} checksum"
|
||||
php -r '
|
||||
$manifestPath = $argv[1];
|
||||
$artifactName = $argv[2];
|
||||
$artifactPath = $argv[3];
|
||||
$manifest = json_decode((string)file_get_contents($manifestPath), true);
|
||||
if (!is_array($manifest)) {
|
||||
fwrite(STDERR, "Invalid artifact manifest: " . $manifestPath . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
$expected = null;
|
||||
foreach ((array)($manifest["artifacts"] ?? []) as $artifact) {
|
||||
if (is_array($artifact) && ($artifact["name"] ?? null) === $artifactName) {
|
||||
$expected = (string)($artifact["sha256"] ?? "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($expected === null || $expected === "") {
|
||||
fwrite(STDERR, "Artifact missing from manifest: " . $artifactName . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
if (!is_file($artifactPath)) {
|
||||
fwrite(STDERR, "Downloaded artifact is missing: " . $artifactPath . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
$actual = hash_file("sha256", $artifactPath);
|
||||
if ($actual === false || !hash_equals($expected, $actual)) {
|
||||
fwrite(STDERR, "Artifact checksum mismatch for " . $artifactName . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
' "$manifest_path" "$artifact_name" "$artifact_path"
|
||||
}
|
||||
cleanup_existing_installation() {
|
||||
if [ "$INSTALL_DIR" != "/opt/truckwash-edge-agent" ]; then
|
||||
echo "Refusing to remove unexpected install directory: $INSTALL_DIR" >&2
|
||||
@@ -2707,6 +2745,7 @@ run_step "Updating package lists" apt-get update
|
||||
run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3
|
||||
run_step "Installing Docker Compose runtime" install_compose_runtime
|
||||
begin_install_phase "DOWNLOAD_ARTIFACTS" "Downloading edge gateway artifacts"
|
||||
fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"
|
||||
fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"
|
||||
fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"
|
||||
fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"
|
||||
@@ -2717,6 +2756,17 @@ fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__"
|
||||
fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh"
|
||||
fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
|
||||
fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"
|
||||
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||
run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"
|
||||
run_step "Verifying LAN worker" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "lan-worker.php" "$INSTALL_DIR/lan-worker.php"
|
||||
run_step "Verifying auto-updater" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "auto-updater.php" "$INSTALL_DIR/auto-updater.php"
|
||||
run_step "Verifying compose stack" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"
|
||||
run_step "Verifying edge-agent Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.edge-agent" "$INSTALL_DIR/Dockerfile.edge-agent"
|
||||
run_step "Verifying lan-worker Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.lan-worker" "$INSTALL_DIR/Dockerfile.lan-worker"
|
||||
run_step "Verifying auto-updater Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.auto-updater" "$INSTALL_DIR/Dockerfile.auto-updater"
|
||||
run_step "Verifying gateway launcher" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "gateway-launcher.sh" "$INSTALL_DIR/gateway-launcher.sh"
|
||||
run_step "Verifying compose stack service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
|
||||
run_step "Verifying compatibility service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"
|
||||
begin_install_phase "WRITE_CONFIG" "Writing gateway configuration"
|
||||
cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON'
|
||||
__CONFIG_JSON__
|
||||
@@ -2745,6 +2795,7 @@ BASH;
|
||||
'__INSTALL_TOKEN__' => $plainToken,
|
||||
'__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken),
|
||||
'__STATUS_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/status',
|
||||
'__MANIFEST_URL__' => $this->buildAgentArtifactUrl('manifest.json'),
|
||||
'__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'),
|
||||
'__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
|
||||
'__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
|
||||
|
||||
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
|
||||
|
||||
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
|
||||
public object_property $deleted_at;
|
||||
const defaultPermissions = [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'ORDERS_LIST',
|
||||
];
|
||||
|
||||
public static function normalizePermissionsValue(mixed $raw): array
|
||||
|
||||
@@ -171,6 +171,7 @@ class subusers_o extends db
|
||||
return $this;
|
||||
} catch (Exception $e) {
|
||||
$response->error($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +218,16 @@ class subusers_o extends db
|
||||
throw new RandomException('Error generating random bytes for setup token', 0, $e);
|
||||
}
|
||||
$cache_key = 'setup_token:' . $token;
|
||||
$cashe_object_id = 'subuser_setup_token';
|
||||
$this->cache($cache_key, $this->id, $cashe_object_id);
|
||||
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
||||
$cache_object_id = 'subuser_setup_token';
|
||||
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
|
||||
if (is_string($existingToken) && $existingToken !== '') {
|
||||
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
|
||||
}
|
||||
$this->cache($cache_key, $this->id, $cache_object_id);
|
||||
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
|
||||
$this->cache($reverse_cache_key, $token, $cache_object_id);
|
||||
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
|
||||
return $token;
|
||||
}
|
||||
|
||||
@@ -260,8 +268,16 @@ class subusers_o extends db
|
||||
public function invalidateSetupToken(string $token): void
|
||||
{
|
||||
$object_id = 'subuser_setup_token';
|
||||
$subuser_id = $this->getSubuserIdBySetupToken($token);
|
||||
$cache_key = 'setup_token:' . $token;
|
||||
$this->deleteCached($cache_key, $object_id);
|
||||
if ($subuser_id !== null) {
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
|
||||
$currentToken = $this->getCached($reverse_cache_key, $object_id);
|
||||
if ((string)$currentToken === $token) {
|
||||
$this->deleteCached($reverse_cache_key, $object_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1108,7 +1108,7 @@ final class TruckwashEdgeAgent
|
||||
return;
|
||||
}
|
||||
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v3');
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
@@ -1264,10 +1264,10 @@ final class TruckwashEdgeAgent
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||
'target_version' => (string)$this->config->get(
|
||||
'targetVersion',
|
||||
$this->config->get('installedVersion', 'compose-php-agent-v2')
|
||||
$this->config->get('installedVersion', 'compose-php-agent-v3')
|
||||
),
|
||||
'metadata' => array_merge([
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
@@ -1964,7 +1964,7 @@ final class TruckwashEdgeAgent
|
||||
|
||||
return [
|
||||
'applied' => false,
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
|
||||
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
|
||||
'staged_version' => $targetVersion,
|
||||
'target_version' => $targetVersion,
|
||||
'staged_at' => $stagedAt,
|
||||
|
||||
@@ -24,6 +24,72 @@ class authRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
|
||||
{
|
||||
return 'passkey_challenge_principal:' . $challengeToken;
|
||||
}
|
||||
|
||||
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
|
||||
}
|
||||
|
||||
private function getPasskeyChallengePrincipal(string $challengeToken): string
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return 'discoverable';
|
||||
}
|
||||
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
|
||||
? $principalType
|
||||
: 'discoverable';
|
||||
}
|
||||
|
||||
private function clearPasskeyChallengePrincipal(string $challengeToken): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
||||
}
|
||||
|
||||
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause(
|
||||
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
|
||||
);
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
if (isset($o['transports'])) {
|
||||
$decoded = json_decode($o['transports'], true);
|
||||
$transports = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
return [
|
||||
'type' => 'public-key',
|
||||
'id' => $o['credential_id'] ?? null,
|
||||
'transports' => $transports,
|
||||
];
|
||||
});
|
||||
|
||||
if (isset($list['items']) && is_array($list['items'])) {
|
||||
$list = $list['items'];
|
||||
}
|
||||
|
||||
return is_array($list)
|
||||
? array_values(array_filter($list, function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->post('/auth/login', function () {
|
||||
@@ -625,11 +691,53 @@ class authRoute
|
||||
global $response;
|
||||
$this->requireRecaptcha();
|
||||
|
||||
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
|
||||
if ($principal_type === '') {
|
||||
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
|
||||
}
|
||||
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
|
||||
$response->error('Invalid principal_type', 400);
|
||||
}
|
||||
|
||||
$customer_number = self::getParameter('customer_number');
|
||||
$user_id = 0;
|
||||
$allowCredentials = [];
|
||||
|
||||
if ($customer_number !== null) {
|
||||
if ($principal_type === 'subuser') {
|
||||
$subuser = null;
|
||||
if (self::isParametersSet(['subuser_id'])) {
|
||||
$subuser_id = (int)self::getParameter('subuser_id');
|
||||
self::requireType($subuser_id, $this->type_int());
|
||||
self::requireMinValue($subuser_id, 1);
|
||||
$candidate = (new subusers_o())->select($subuser_id);
|
||||
if ($candidate->exists()) {
|
||||
$candidate->getObjectProperties();
|
||||
$subuser = $candidate;
|
||||
}
|
||||
} elseif (self::isParametersSet(['username'])) {
|
||||
$username = (string)self::getParameter('username');
|
||||
self::requireType($username, $this->type_string());
|
||||
self::requireMinLength('username', 3);
|
||||
self::requireMaxLength('username', 255);
|
||||
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
||||
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
|
||||
$phone_country_code = (int)self::getParameter('phone_country_code');
|
||||
$phone = (int)self::getParameter('phone');
|
||||
self::requireType($phone_country_code, $this->type_int());
|
||||
self::requireMinLength('phone_country_code', 1);
|
||||
self::requireMaxLength('phone_country_code', 3);
|
||||
self::requireType($phone, $this->type_int());
|
||||
self::requireMinLength('phone', 4);
|
||||
self::requireMaxLength('phone', 15);
|
||||
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
||||
}
|
||||
|
||||
if ($subuser !== null) {
|
||||
$user_id = (int)$subuser->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||
}
|
||||
} elseif ($customer_number !== null) {
|
||||
$principal_type = 'user';
|
||||
$customer_number = (int)$customer_number;
|
||||
self::requireType($customer_number, $this->type_int());
|
||||
self::requireMinValue($customer_number, 1);
|
||||
@@ -639,33 +747,7 @@ class authRoute
|
||||
|
||||
if ($user->exists()) {
|
||||
$user_id = (int)$user->id;
|
||||
|
||||
// Load passkeys for this user (non-subuser)
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
if (isset($o['transports'])) {
|
||||
$decoded = json_decode($o['transports'], true);
|
||||
$transports = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
return [
|
||||
'type' => 'public-key',
|
||||
'id' => $o['credential_id'] ?? null,
|
||||
'transports' => $transports,
|
||||
];
|
||||
});
|
||||
|
||||
// Ensure we return a simple array of credentials (without pagination wrapper)
|
||||
if (isset($list['items']) && is_array($list['items'])) {
|
||||
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
} elseif (is_array($list)) {
|
||||
$allowCredentials = array_values(array_filter($list, function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
}
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +764,10 @@ class authRoute
|
||||
|
||||
// Create an ephemeral token to bind the challenge to the (potential) user
|
||||
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
||||
$this->setPasskeyChallengePrincipal(
|
||||
$challenge_token,
|
||||
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
|
||||
);
|
||||
|
||||
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
|
||||
@@ -733,6 +819,7 @@ class authRoute
|
||||
if ($token_type !== 'PASSKEY_CHALLENGE') {
|
||||
$response->error('Invalid token type', 401);
|
||||
}
|
||||
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
|
||||
|
||||
// Determine rpId/host
|
||||
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
||||
@@ -768,7 +855,17 @@ class authRoute
|
||||
// Success → issue session token accordingly and delete the challenge token
|
||||
$issued_to_user_id = (int)$passkey->user_id->value();
|
||||
$is_subuser = (bool)$passkey->is_subuser->value();
|
||||
if (
|
||||
($challengePrincipalType === 'subuser' && !$is_subuser)
|
||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||
) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
$response->error('Invalid credential', 401);
|
||||
}
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
|
||||
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
|
||||
|
||||
|
||||
@@ -11,21 +11,48 @@ class passkeysRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
/**
|
||||
* @return array{principal:object,user_id:int,is_subuser:bool}
|
||||
*/
|
||||
private function resolvePasskeyPrincipal(string $classicUserPermission): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$auth = new authentication();
|
||||
$subuser = $auth->get_subuser();
|
||||
if ($subuser !== false) {
|
||||
return [
|
||||
'principal' => $subuser,
|
||||
'user_id' => (int)$subuser->id,
|
||||
'is_subuser' => true,
|
||||
];
|
||||
}
|
||||
|
||||
self::requirePermission($classicUserPermission);
|
||||
$user = $auth->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_AUTH', 'User not logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'principal' => $user,
|
||||
'user_id' => (int)$user->id,
|
||||
'is_subuser' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// List passkeys for current authenticated user
|
||||
$this->get('/account/security/passkeys', function () {
|
||||
global $response;
|
||||
self::requirePermission('user_security_passkeys_list');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
|
||||
|
||||
$passkeys = new passkeys_o();
|
||||
// Restrict to current user (customer) and non-subuser records
|
||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user->id . ' AND `is_subuser` = 0');
|
||||
$passkeys->setAdditionalWhereClause(
|
||||
'`user_id` = ' . (int)$principal['user_id'] . ' AND `is_subuser` = ' . ($principal['is_subuser'] ? '1' : '0')
|
||||
);
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
// $o is an associative array from the database
|
||||
$transports = null;
|
||||
@@ -45,7 +72,7 @@ class passkeysRoute
|
||||
];
|
||||
});
|
||||
|
||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
|
||||
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
|
||||
$response->success($list);
|
||||
}, [
|
||||
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
|
||||
@@ -54,12 +81,7 @@ class passkeysRoute
|
||||
// Create/add a passkey (store after client-side WebAuthn attestation)
|
||||
$this->post('/account/security/passkeys', function () {
|
||||
global $response;
|
||||
self::requirePermission('user_security_passkeys_create');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
|
||||
|
||||
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
|
||||
|
||||
@@ -100,9 +122,9 @@ class passkeysRoute
|
||||
}
|
||||
|
||||
$obj = new passkeys_o();
|
||||
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
|
||||
$obj->add((int)$principal['user_id'], (bool)$principal['is_subuser'], $credential_id, $public_key, $algorithm, (array)$transports, $name);
|
||||
|
||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
|
||||
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
|
||||
$response->success(['id' => $obj->id]);
|
||||
}, [
|
||||
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
|
||||
@@ -111,12 +133,7 @@ class passkeysRoute
|
||||
// Rename a passkey
|
||||
$this->patch('/account/security/passkeys/{id}', function () {
|
||||
global $response;
|
||||
self::requirePermission('user_security_passkeys_rename');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
|
||||
|
||||
$id = (int)self::fromRoute('id');
|
||||
self::requireParameterIntPositive($id, 'id');
|
||||
@@ -127,13 +144,17 @@ class passkeysRoute
|
||||
self::requireMaxLength('name', 255);
|
||||
|
||||
$obj = (new passkeys_o())->select($id);
|
||||
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
||||
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
|
||||
if (
|
||||
!$obj->exists()
|
||||
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|
||||
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
|
||||
) {
|
||||
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
|
||||
$response->error('Not found', 404);
|
||||
}
|
||||
|
||||
$obj->update(['name' => $name]);
|
||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
|
||||
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
|
||||
$response->success(['message' => 'Renamed', 'id' => $id]);
|
||||
}, [
|
||||
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
|
||||
@@ -142,24 +163,23 @@ class passkeysRoute
|
||||
// Delete a passkey (soft delete)
|
||||
$this->delete('/account/security/passkeys/{id}', function () {
|
||||
global $response;
|
||||
self::requirePermission('user_security_passkeys_delete');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');
|
||||
|
||||
$id = (int)self::fromRoute('id');
|
||||
self::requireParameterIntPositive($id, 'id');
|
||||
|
||||
$obj = (new passkeys_o())->select($id);
|
||||
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
||||
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
|
||||
if (
|
||||
!$obj->exists()
|
||||
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|
||||
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
|
||||
) {
|
||||
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
|
||||
$response->error('Not found', 404);
|
||||
}
|
||||
|
||||
$obj->delete();
|
||||
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
|
||||
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
|
||||
$response->success(['message' => 'Deleted', 'id' => $id]);
|
||||
}, [
|
||||
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
|
||||
|
||||
@@ -231,7 +231,57 @@ class subusersRoute
|
||||
|
||||
private function buildSetupLink(string $token): string
|
||||
{
|
||||
return 'https://truckwash.io/complete-registration?token=' . $token;
|
||||
$frontendBaseUrl = trim((string)(
|
||||
getenv('FRONTEND_URL')
|
||||
?: getenv('APP_URL')
|
||||
?: ($_SERVER['FRONTEND_URL'] ?? '')
|
||||
?: ($_SERVER['APP_URL'] ?? '')
|
||||
?: 'https://truckwash.io'
|
||||
));
|
||||
$frontendBaseUrl = rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/');
|
||||
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
|
||||
}
|
||||
|
||||
private function clientThrottleIp(): string
|
||||
{
|
||||
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
|
||||
}
|
||||
|
||||
private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!defined('redis')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
|
||||
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
|
||||
'sha256',
|
||||
$this->clientThrottleIp() . ':' . $identifier
|
||||
);
|
||||
$redis = constant('redis');
|
||||
$attempts = (int)($redis->get($key) ?? '0');
|
||||
if ($attempts >= $limit) {
|
||||
$response->error('Too many attempts. Please wait and try again.', 429);
|
||||
}
|
||||
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function clearThrottleAttempt(?string $key): void
|
||||
{
|
||||
if ($key === null || !defined('redis')) {
|
||||
return;
|
||||
}
|
||||
constant('redis')->delete($key);
|
||||
}
|
||||
|
||||
private function subuserAuthFailure(): void
|
||||
{
|
||||
global $response;
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
private function issueSetupInvite(subusers_o $subuser): array
|
||||
@@ -272,7 +322,7 @@ class subusersRoute
|
||||
$delivery = [
|
||||
'channel' => 'sms',
|
||||
'status' => 'failed',
|
||||
'message' => $exception->getMessage(),
|
||||
'message' => 'Invite delivery failed.',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -330,7 +380,7 @@ class subusersRoute
|
||||
'subuser' => $subuser->id,
|
||||
'enabled' => 1,
|
||||
'deleted_at' => null,
|
||||
], ['permissions', 'billing_customer_number']);
|
||||
], ['id', 'permissions', 'billing_customer_number']);
|
||||
$customerNames = $this->resolveCustomerNames(array_map(
|
||||
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
|
||||
$grants
|
||||
@@ -346,6 +396,7 @@ class subusersRoute
|
||||
'grants' => array_map(function ($grant) use ($customerNames) {
|
||||
$customerNumber = (int)$grant['billing_customer_number'];
|
||||
return [
|
||||
'grant_id' => isset($grant['id']) ? (int)$grant['id'] : null,
|
||||
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
|
||||
'billing_customer_number' => $customerNumber,
|
||||
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
|
||||
@@ -762,28 +813,9 @@ class subusersRoute
|
||||
self::requireType($note, self::type_string());
|
||||
self::requireMaxLength('note', 65535);
|
||||
}
|
||||
$permissions = null;
|
||||
if (self::isParametersSet(['permissions'])) {
|
||||
$raw = self::getParameter('permissions');
|
||||
// Expect array (already parsed) or JSON string
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$response->error('Invalid permissions payload', 400);
|
||||
}
|
||||
$permissions = $decoded;
|
||||
} elseif (is_array($raw)) {
|
||||
$permissions = $raw;
|
||||
} else {
|
||||
$response->error('Invalid permissions type', 400);
|
||||
}
|
||||
// Validate each permission is a known key
|
||||
foreach ($permissions as $perm) {
|
||||
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
||||
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
$permissions = self::isParametersSet(['permissions'])
|
||||
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||
: null;
|
||||
try {
|
||||
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
||||
$response->success(['grant' => $grant->asArray()]);
|
||||
@@ -834,25 +866,7 @@ class subusersRoute
|
||||
$grant->note->set($note);
|
||||
}
|
||||
if (self::isParametersSet(['permissions'])) {
|
||||
$raw = self::getParameter('permissions');
|
||||
// Expect array (already parsed) or JSON string
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$response->error('Invalid permissions payload', 400);
|
||||
}
|
||||
$permissions = $decoded;
|
||||
} elseif (is_array($raw)) {
|
||||
$permissions = $raw;
|
||||
} else {
|
||||
$response->error('Invalid permissions type', 400);
|
||||
}
|
||||
// Validate each permission is a known key
|
||||
foreach ($permissions as $perm) {
|
||||
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
||||
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
||||
}
|
||||
}
|
||||
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
|
||||
$grant->permissions->set($permissions);
|
||||
}
|
||||
$response->success($grant->asArray());
|
||||
@@ -866,6 +880,14 @@ class subusersRoute
|
||||
|
||||
$this->get('/subusers/permission-nodes', function () {
|
||||
global $response;
|
||||
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|
||||
|| self::hasPermission('list_subusers')
|
||||
|| self::hasPermission('add_subusers')
|
||||
|| self::hasPermission('edit_subusers');
|
||||
if (!$canUseGlobalManagement) {
|
||||
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
|
||||
}
|
||||
|
||||
// Build groups
|
||||
$groups = [
|
||||
new subusers_permission_nodes_bookings(),
|
||||
@@ -893,7 +915,13 @@ class subusersRoute
|
||||
];
|
||||
}
|
||||
$response->success($out);
|
||||
}, []);
|
||||
}, [
|
||||
'list_own_subusers' => 'List chauffeur permission nodes for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
|
||||
'manage_subuser_grants' => 'List chauffeur permission nodes for administrative grant management.',
|
||||
'list_subusers' => 'List chauffeur permission nodes for superuser management.',
|
||||
'add_subusers' => 'List chauffeur permission nodes while inviting chauffeurs.',
|
||||
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
|
||||
]);
|
||||
|
||||
$this->post('/subusers', function () {
|
||||
global /** @var response $response */
|
||||
@@ -948,15 +976,7 @@ class subusersRoute
|
||||
(int)$phone_country_code,
|
||||
(int)$phone
|
||||
);
|
||||
// Send an SMS with a link to complete the registration process.
|
||||
$gatewayAPI = new gatewayapi();
|
||||
if ($gatewayAPI->isEnabled()) {
|
||||
$token = $subuser->generateSetupToken();
|
||||
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
||||
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
||||
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
||||
$gatewayAPI->send($phone_number_array, $message);
|
||||
}
|
||||
$invite = $this->issueSetupInvite($subuser);
|
||||
// Add the grant request
|
||||
$subuser_grants_o = new subuser_grants_o();
|
||||
try {
|
||||
@@ -964,7 +984,7 @@ class subusersRoute
|
||||
} catch (Exception $e) {
|
||||
$response->error('Failed to add subuser grant', 500);
|
||||
}
|
||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
||||
// Code for creating a new subuser would go here
|
||||
});
|
||||
$this->get('/subusers/setup', function () {
|
||||
@@ -972,11 +992,13 @@ class subusersRoute
|
||||
global $response;
|
||||
self::requireParameters(['token']);
|
||||
$token = self::getParameter('token');
|
||||
$setupThrottleKey = $this->recordThrottleAttempt('setup_token', 'token-validation', 20, 15 * 60);
|
||||
// Get the subuser with the setup token
|
||||
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
|
||||
if ($subuser === null) {
|
||||
$response->error('Invalid or expired token', 400);
|
||||
}
|
||||
$this->clearThrottleAttempt($setupThrottleKey);
|
||||
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
|
||||
});
|
||||
$this->post('/subusers/setup', function () {
|
||||
@@ -986,6 +1008,7 @@ class subusersRoute
|
||||
$token = (string)self::getParameter('token');
|
||||
$password = (string)self::getParameter('password');
|
||||
$name = (string)self::getParameter('name');
|
||||
$setupThrottleKey = $this->recordThrottleAttempt('setup_complete', 'token-complete', 20, 15 * 60);
|
||||
$this->requireSubuserPasswordPolicy($password);
|
||||
self::requireType($name, self::type_string());
|
||||
self::requireMinLength('name', 3);
|
||||
@@ -1017,18 +1040,24 @@ class subusersRoute
|
||||
if ($subuser === null) {
|
||||
$response->error('Invalid or expired token', 400);
|
||||
}
|
||||
$this->assertSubuserIdentifiersAvailable(
|
||||
null,
|
||||
null,
|
||||
$username,
|
||||
$email,
|
||||
(int)$subuser->id
|
||||
);
|
||||
// Set the password for the subuser
|
||||
try {
|
||||
$subuser->setPassword($password);
|
||||
if (!empty($username) || !empty($email) || !empty($name)) {
|
||||
$subuser->update([
|
||||
...(!empty($username) ? ['username' => $username] : []),
|
||||
...(!empty($email) ? ['email' => $email] : []),
|
||||
...(!empty($name) ? ['name' => $name] : []),
|
||||
]);
|
||||
}
|
||||
$subuser->update([
|
||||
'password' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'name' => $name,
|
||||
...(!empty($username) ? ['username' => $username] : []),
|
||||
...(!empty($email) ? ['email' => $email] : []),
|
||||
]);
|
||||
// Invalidate the setup token
|
||||
$subuser->invalidateSetupToken($token);
|
||||
$this->clearThrottleAttempt($setupThrottleKey);
|
||||
$response->success(['message' => 'Complete registration successful']);
|
||||
} catch (Exception $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
@@ -1067,23 +1096,34 @@ class subusersRoute
|
||||
} else {
|
||||
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
|
||||
}
|
||||
$identifier = $username !== null
|
||||
? 'username:' . strtolower($username)
|
||||
: ($subuser_id !== null
|
||||
? 'id:' . (string)$subuser_id
|
||||
: 'phone:' . (string)$phone_country_code . ':' . (string)$phone);
|
||||
$authThrottleKey = $this->recordThrottleAttempt('auth_password', $identifier, 10, 15 * 60);
|
||||
// Get the subuser based on the provided username type
|
||||
$subuser = null;
|
||||
if ($phone_country_code !== null && $phone !== null) {
|
||||
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
||||
} elseif ($subuser_id !== null) {
|
||||
$subuser = (new subusers_o())->select($subuser_id);
|
||||
$candidate = (new subusers_o())->select($subuser_id);
|
||||
if ($candidate->exists()) {
|
||||
$candidate->getObjectProperties();
|
||||
$subuser = $candidate;
|
||||
}
|
||||
} elseif ($username !== null) {
|
||||
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
||||
}
|
||||
if ($subuser === null) {
|
||||
$response->error('Subuser not found', 404);
|
||||
$this->subuserAuthFailure();
|
||||
}
|
||||
self::requireParameters(['password']);
|
||||
$password = (string)self::getParameter('password');
|
||||
$this->requireSubuserPasswordPolicy($password);
|
||||
try {
|
||||
if (password_verify($password, $subuser->password->value())) {
|
||||
$passwordHash = $subuser->password->value();
|
||||
if (is_string($passwordHash) && $passwordHash !== '' && password_verify($password, $passwordHash)) {
|
||||
$this->clearThrottleAttempt($authThrottleKey);
|
||||
if ($subuser->isTwoFactorEnabled()) {
|
||||
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
|
||||
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
||||
@@ -1092,7 +1132,7 @@ class subusersRoute
|
||||
$session = $subuser->generateSession();
|
||||
$response->success(['session' => $session]);
|
||||
} else {
|
||||
$response->error('Invalid password', 400);
|
||||
$this->subuserAuthFailure();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$response->error($e->getMessage(), 500);
|
||||
@@ -1363,15 +1403,7 @@ class subusersRoute
|
||||
(int)$phone
|
||||
);
|
||||
|
||||
// Optionally send SMS with setup link
|
||||
$gatewayAPI = new gatewayapi();
|
||||
if ($gatewayAPI->isEnabled()) {
|
||||
$token = $subuser->generateSetupToken();
|
||||
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
||||
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
||||
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
||||
$gatewayAPI->send($phone_number_array, $message);
|
||||
}
|
||||
$invite = $this->issueSetupInvite($subuser);
|
||||
|
||||
// Create a pending grant request for the company
|
||||
$subuser_grants_o = new subuser_grants_o();
|
||||
@@ -1381,7 +1413,7 @@ class subusersRoute
|
||||
$response->error('Failed to add subuser grant', 500);
|
||||
}
|
||||
|
||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,67 @@ it('logs out and invalidates cached subuser sessions', function (): void {
|
||||
->assertMessage('Unauthorized');
|
||||
});
|
||||
|
||||
it('scopes passkey challenges to the requested principal type', function (): void {
|
||||
api_test_covers('POST /auth/passkey/challenge', 'auth');
|
||||
|
||||
api_fixtures()->setModuleConfig('reCAPTCHA', 'enabled', 'false');
|
||||
$user = api_fixtures()->createUser([
|
||||
'display_name' => 'Passkey Customer',
|
||||
]);
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'username' => 'passkey-driver',
|
||||
]);
|
||||
api_fixtures()->createPasskey([
|
||||
'user_id' => (int)$user['id'],
|
||||
'is_subuser' => false,
|
||||
'credential_id' => 'customer-passkey-credential',
|
||||
]);
|
||||
api_fixtures()->createPasskey([
|
||||
'user_id' => (int)$subuser['id'],
|
||||
'is_subuser' => true,
|
||||
'credential_id' => 'subuser-passkey-credential',
|
||||
]);
|
||||
|
||||
$subuserChallenge = api_client()->post('/auth/passkey/challenge', [
|
||||
'principal_type' => 'subuser',
|
||||
'username' => 'passkey-driver',
|
||||
]);
|
||||
$userChallenge = api_client()->post('/auth/passkey/challenge', [
|
||||
'principal_type' => 'user',
|
||||
'customer_number' => (int)$user['customer_number'],
|
||||
]);
|
||||
|
||||
$subuserChallenge
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$userChallenge
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$subuserCredentials = $subuserChallenge->data()['publicKey']['allowCredentials'] ?? [];
|
||||
$userCredentials = $userChallenge->data()['publicKey']['allowCredentials'] ?? [];
|
||||
|
||||
$subuserCredentialIds = array_column($subuserCredentials, 'id');
|
||||
$userCredentialIds = array_column($userCredentials, 'id');
|
||||
|
||||
expect($subuserCredentialIds)->toContain('subuser-passkey-credential');
|
||||
expect($subuserCredentialIds)->not->toContain('customer-passkey-credential');
|
||||
expect($userCredentialIds)->toContain('customer-passkey-credential');
|
||||
expect($userCredentialIds)->not->toContain('subuser-passkey-credential');
|
||||
|
||||
$tokenRepo = new \objects\tokens_o();
|
||||
$subuserChallengeToken = (string)($subuserChallenge->data()['challenge_token'] ?? '');
|
||||
$userChallengeToken = (string)($userChallenge->data()['challenge_token'] ?? '');
|
||||
if ($subuserChallengeToken !== '') {
|
||||
$tokenRepo->delete($subuserChallengeToken);
|
||||
}
|
||||
if ($userChallengeToken !== '') {
|
||||
$tokenRepo->delete($userChallengeToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid logout tokens', function (): void {
|
||||
api_test_covers('GET /auth/logout', 'auth');
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h
|
||||
->and($artifact->body)
|
||||
->toContain('<?php');
|
||||
|
||||
$manifest = api_client()->get('/edge-agent/artifacts/manifest.json');
|
||||
expect($manifest->status)->toBe(200);
|
||||
$manifestPayload = json_decode($manifest->body, true);
|
||||
expect($manifestPayload)->toBeArray()
|
||||
->and($manifestPayload['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
|
||||
$manifestArtifacts = array_column((array)($manifestPayload['artifacts'] ?? []), null, 'name');
|
||||
expect($manifestArtifacts)->toHaveKey('agent.php')
|
||||
->and($manifestArtifacts['agent.php']['sha256'] ?? null)->toBe(hash('sha256', $artifact->body));
|
||||
|
||||
$claimResponse = api_client()->post('/edge-agent/claim', [
|
||||
'token' => (string)$installToken['token'],
|
||||
'hostname' => 'edge-agent-api',
|
||||
|
||||
@@ -44,11 +44,13 @@ it('lists subusers when an existing grant has legacy zero permissions', function
|
||||
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
|
||||
});
|
||||
|
||||
it('uses the same password policy for subuser setup and password auth', function (): void {
|
||||
it('enforces the password policy for setup without rejecting legacy valid passwords at login', function (): void {
|
||||
api_test_covers('POST /subusers/setup', 'failure');
|
||||
api_test_covers('POST /subusers/auth/password', 'failure');
|
||||
api_test_covers('POST /subusers/auth/password', 'happy');
|
||||
|
||||
$subuser = api_fixtures()->createSubuser();
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'password_plaintext' => 'invalidpassword',
|
||||
]);
|
||||
|
||||
$setupResponse = api_client()->post('/subusers/setup', [
|
||||
'token' => 'policy-test-token',
|
||||
@@ -68,10 +70,114 @@ it('uses the same password policy for subuser setup and password auth', function
|
||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
||||
|
||||
$authResponse
|
||||
->assertStatus(400)
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($authResponse->data()['session'] ?? null)->toBeString();
|
||||
(new \objects\subusers_o())->invalidateSessionToken((string)$authResponse->data()['session']);
|
||||
});
|
||||
|
||||
it('returns a generic error for invalid subuser credentials', function (): void {
|
||||
api_test_covers('POST /subusers/auth/password', 'failure');
|
||||
|
||||
$response = api_client()->post('/subusers/auth/password', [
|
||||
'subuser_id' => 999999999,
|
||||
'password' => 'whatever',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
||||
->assertMessage('Invalid credentials');
|
||||
});
|
||||
|
||||
it('requires subuser management access before exposing permission nodes', function (): void {
|
||||
api_test_covers('GET /subusers/permission-nodes', 'auth');
|
||||
|
||||
$unauthenticated = api_client()->get('/subusers/permission-nodes');
|
||||
$unauthenticated
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$session = api_fixtures()->createUserSession(['list_own_subusers']);
|
||||
$authorized = api_client()->get('/subusers/permission-nodes', $session['headers']);
|
||||
$authorized
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($authorized->data())->toBeArray()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects customer subuser listing without own-scope permission', function (): void {
|
||||
api_test_covers('GET /subusers', 'auth');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['user']);
|
||||
$response = api_client()->get('/subusers?page=1&limit=5', $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
});
|
||||
|
||||
it('replaces older setup tokens when a new setup token is generated', function (): void {
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'password_plaintext' => null,
|
||||
'name' => 'Pending Setup Driver',
|
||||
]);
|
||||
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
|
||||
$subuserObject->getObjectProperties();
|
||||
|
||||
$firstToken = $subuserObject->generateSetupToken();
|
||||
$secondToken = $subuserObject->generateSetupToken();
|
||||
|
||||
try {
|
||||
expect((new \objects\subusers_o())->getSubuserBySetupToken($firstToken))->toBeNull();
|
||||
expect((new \objects\subusers_o())->getSubuserBySetupToken($secondToken))->not->toBeNull();
|
||||
} finally {
|
||||
(new \objects\subusers_o())->invalidateSetupToken($secondToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates setup identifiers before setting the driver password', function (): void {
|
||||
api_test_covers('POST /subusers/setup', 'failure');
|
||||
|
||||
$existing = api_fixtures()->createSubuser([
|
||||
'username' => 'existing-driver-setup',
|
||||
]);
|
||||
$pending = api_fixtures()->createSubuser([
|
||||
'password_plaintext' => null,
|
||||
'username' => 'pending-driver-setup',
|
||||
'name' => 'Pending Driver',
|
||||
]);
|
||||
$pendingObject = (new \objects\subusers_o())->select((int)$pending['id']);
|
||||
$pendingObject->getObjectProperties();
|
||||
$token = $pendingObject->generateSetupToken();
|
||||
|
||||
try {
|
||||
$response = api_client()->post('/subusers/setup', [
|
||||
'token' => $token,
|
||||
'name' => 'Pending Driver',
|
||||
'username' => $existing['username'],
|
||||
'password' => 'ValidPass123',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Account already exists with this username');
|
||||
|
||||
$row = api_fixtures()->fetchRowById('subusers', (int)$pending['id']);
|
||||
expect($row['password'] ?? null)->toBeNull();
|
||||
expect((new \objects\subusers_o())->getSubuserBySetupToken($token))->not->toBeNull();
|
||||
} finally {
|
||||
(new \objects\subusers_o())->invalidateSetupToken($token);
|
||||
}
|
||||
});
|
||||
|
||||
it('lists chauffeur grants across customers for superusers', function (): void {
|
||||
|
||||
@@ -916,14 +916,16 @@ final class ApiFixtures
|
||||
public function createSubuser(array $attributes = []): array
|
||||
{
|
||||
$username = (string)($attributes['username'] ?? ('api-subuser-' . strtolower($this->uniqueSuffix())));
|
||||
$passwordPlaintext = (string)($attributes['password_plaintext'] ?? 'Secret123!');
|
||||
$passwordPlaintext = array_key_exists('password_plaintext', $attributes)
|
||||
? $attributes['password_plaintext']
|
||||
: 'Secret123!';
|
||||
$name = (string)($attributes['name'] ?? 'API Subuser');
|
||||
$email = (string)($attributes['email'] ?? ($username . '@example.test'));
|
||||
$now = $this->now();
|
||||
|
||||
$subuserId = $this->insertRow('subusers', [
|
||||
'username' => $username,
|
||||
'password' => password_hash($passwordPlaintext, PASSWORD_DEFAULT),
|
||||
'password' => $passwordPlaintext === null ? null : password_hash((string)$passwordPlaintext, PASSWORD_DEFAULT),
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'phone_country_code' => 45,
|
||||
@@ -1064,6 +1066,42 @@ final class ApiFixtures
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createPasskey(array $attributes): array
|
||||
{
|
||||
$userId = (int)($attributes['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
throw new RuntimeException('Passkey fixtures require user_id.');
|
||||
}
|
||||
|
||||
$credentialId = (string)($attributes['credential_id'] ?? ('credential-' . strtolower($this->uniqueSuffix())));
|
||||
$passkeyId = $this->insertRow('passkeys', [
|
||||
'user_id' => $userId,
|
||||
'is_subuser' => !empty($attributes['is_subuser']) ? 1 : 0,
|
||||
'credential_id' => $credentialId,
|
||||
'public_key' => $attributes['public_key'] ?? str_repeat('A', 64),
|
||||
'algorithm' => $attributes['algorithm'] ?? 'ES256',
|
||||
'transports' => json_encode($attributes['transports'] ?? ['internal'], JSON_UNESCAPED_SLASHES),
|
||||
'sign_count' => (int)($attributes['sign_count'] ?? 0),
|
||||
'backup_state' => json_encode($attributes['backup_state'] ?? new \stdClass(), JSON_UNESCAPED_SLASHES),
|
||||
'name' => $attributes['name'] ?? 'API test passkey',
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('passkeys', $passkeyId));
|
||||
|
||||
return [
|
||||
'id' => $passkeyId,
|
||||
'credential_id' => $credentialId,
|
||||
'user_id' => $userId,
|
||||
];
|
||||
}
|
||||
|
||||
public function addCustomerAttribute(int $userId, string $attribute): int
|
||||
{
|
||||
$attributeId = $this->insertRow('customer_attributes', [
|
||||
|
||||
@@ -65,12 +65,16 @@ function remove_edge_gateway_temp_path(string $path): void
|
||||
|
||||
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
|
||||
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
|
||||
$normalizedPath = str_replace('\\', '/', $path);
|
||||
|
||||
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
|
||||
expect(
|
||||
str_ends_with($normalizedPath, '/edge-agent/build/install/agent.php')
|
||||
|| str_ends_with($normalizedPath, '/resources/edge-gateway-agent/agent.php')
|
||||
)->toBeTrue();
|
||||
expect(is_file($path))->toBeTrue();
|
||||
});
|
||||
|
||||
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
|
||||
it('prioritizes generated edge-agent build output before router, mounted, and baked-in artifact directories', function (): void {
|
||||
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
|
||||
$candidatePaths = array_map(
|
||||
static fn(string $path): string => str_replace('\\', '/', $path),
|
||||
@@ -82,16 +86,75 @@ it('prioritizes router resources before mounted and baked-in artifact directorie
|
||||
)
|
||||
);
|
||||
|
||||
expect(array_slice($candidatePaths, 0, 3))->toBe([
|
||||
expect(array_slice($candidatePaths, 0, 5))->toBe([
|
||||
'/edge-agent/build/install/agent.php',
|
||||
'/services/edge-agent/build/install/agent.php',
|
||||
'/var/edge-agent/build/install/agent.php',
|
||||
'/var/www/edge-agent/build/install/agent.php',
|
||||
'/var/www/html/resources/edge-gateway-agent/agent.php',
|
||||
'/services/edge-agent/php-agent/agent.php',
|
||||
'/opt/truckwash-edge-agent-artifacts/agent.php',
|
||||
]);
|
||||
expect($candidatePaths)->toContain('/services/edge-agent/php-agent/agent.php');
|
||||
expect($candidatePaths)->toContain('/opt/truckwash-edge-agent-artifacts/agent.php');
|
||||
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
|
||||
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritizes EDGE_AGENT_ARTIFACT_DIR before generated edge-agent build output', function (): void {
|
||||
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => '/tmp/custom-edge-artifacts'], function (): void {
|
||||
$candidatePaths = array_map(
|
||||
static fn(string $path): string => str_replace('\\', '/', $path),
|
||||
edge_gateway_agent_artifact_locator::candidatePaths(
|
||||
'agent.php',
|
||||
'/var/www/html',
|
||||
'/services/edge-agent/php-agent',
|
||||
'/opt/truckwash-edge-agent-artifacts'
|
||||
)
|
||||
);
|
||||
|
||||
expect(array_slice($candidatePaths, 0, 4))->toBe([
|
||||
'/tmp/custom-edge-artifacts/agent.php',
|
||||
'/edge-agent/build/install/agent.php',
|
||||
'/services/edge-agent/build/install/agent.php',
|
||||
'/var/edge-agent/build/install/agent.php',
|
||||
]);
|
||||
expect($candidatePaths)->toContain('/var/www/html/resources/edge-gateway-agent/agent.php');
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a manifest with hashes for the resolved artifacts', function (): void {
|
||||
$service = new EdgeGatewayInstallServiceHarness();
|
||||
$manifest = json_decode($service->readArtifact('manifest.json'), true);
|
||||
|
||||
expect($manifest)->toBeArray()
|
||||
->and($manifest['version'] ?? null)->toBe(edge_gateway_manager::DEFAULT_INSTALL_VERSION);
|
||||
|
||||
$artifacts = [];
|
||||
foreach ((array)($manifest['artifacts'] ?? []) as $artifact) {
|
||||
if (is_array($artifact)) {
|
||||
$artifacts[(string)($artifact['name'] ?? '')] = $artifact;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'agent.php',
|
||||
'lan-worker.php',
|
||||
'auto-updater.php',
|
||||
'docker-compose.gateway.yml',
|
||||
'Dockerfile.edge-agent',
|
||||
'Dockerfile.lan-worker',
|
||||
'Dockerfile.auto-updater',
|
||||
'gateway-launcher.sh',
|
||||
'truckwash-edge-gateway-stack.service',
|
||||
'truckwash-edge-agent.service',
|
||||
] as $fileName) {
|
||||
$path = edge_gateway_agent_artifact_locator::resolve($fileName, app_path());
|
||||
expect($artifacts)->toHaveKey($fileName)
|
||||
->and($artifacts[$fileName]['sha256'] ?? null)->toBe(hash_file('sha256', $path))
|
||||
->and($artifacts[$fileName]['bytes'] ?? null)->toBe(filesize($path));
|
||||
}
|
||||
});
|
||||
|
||||
it('reads install artifacts through the shared locator', function (): void {
|
||||
$service = new EdgeGatewayInstallServiceHarness();
|
||||
$contents = $service->readArtifact('truckwash-edge-gateway-stack.service');
|
||||
|
||||
@@ -75,10 +75,13 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
|
||||
expect($script)->toContain('fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json" "$INSTALL_DIR/manifest.json"');
|
||||
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
|
||||
expect($script)->toContain('verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($script)->toContain('report_install_status() {');
|
||||
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
|
||||
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
|
||||
@@ -98,6 +101,7 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
||||
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
|
||||
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
||||
expect($script)->toContain('"installedVersion":"compose-php-agent-v3"');
|
||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
|
||||
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||
expect($route)->toContain("'/edge-agent/install-token/status'");
|
||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/manifest.json'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
|
||||
|
||||
@@ -24,13 +24,19 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($autoUpdaterSource)->not->toBeFalse();
|
||||
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
|
||||
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
|
||||
expect($managerSource)->toContain("public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3'");
|
||||
expect($managerSource)->toContain("'installedVersion' => self::DEFAULT_INSTALL_VERSION");
|
||||
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
|
||||
expect($managerSource)->toContain('fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"');
|
||||
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
|
||||
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($managerSource)->toContain('verify_manifest_artifact()');
|
||||
expect($managerSource)->toContain('begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"');
|
||||
expect($managerSource)->toContain('run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
|
||||
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
|
||||
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
|
||||
@@ -72,6 +78,8 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($managerSource)->not->toContain('agent.mjs');
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($installServiceSource)->toContain('normalizeLineEndings($this->manager()->buildInstallScript($plainToken))');
|
||||
expect($installServiceSource)->toContain("'manifest.json' => 'application/json; charset=utf-8'");
|
||||
expect($installServiceSource)->toContain("'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION");
|
||||
expect($installServiceSource)->toContain('str_replace(["\r\n", "\r"], "\n", $contents)');
|
||||
expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit');
|
||||
expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
@@ -129,6 +137,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($composeSource)->toContain('container_name: truckwash-minio');
|
||||
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
|
||||
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
|
||||
expect($agentSource)->toContain('compose-php-agent-v3');
|
||||
expect($agentSource)->toContain('control-plane-status.json');
|
||||
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
|
||||
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
|
||||
|
||||
Reference in New Issue
Block a user