build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers
Implements MeshVault M1 (issues #6–11): - Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets) - Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling - Immutable validated AppConfig with redacted diagnostics - JSON stdout Monolog logging with job-scoped context - Tailwind CSS v4 deterministic locked builds with template scanning - POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch) - Startup config validation fails before FPM serves as unprivileged www-data - migrate/seed explicitly unavailable until M2 (#12); #10 remains open Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
1 parent
c2747a5762
commit
d3e8cf8fae
39 files changed
+5291
-38
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# Explicit build inputs only: exclude secrets, collections and host dependencies.
|
||||
**
|
||||
!docker/
|
||||
!docker/php/
|
||||
!docker/php/**
|
||||
!composer.json
|
||||
!composer.lock
|
||||
!bin/
|
||||
!bin/**
|
||||
!docker/assets/
|
||||
!docker/assets/**
|
||||
!package.json
|
||||
!package-lock.json
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copy to ignored .env and choose a private password before starting.
|
||||
COMPOSE_PROJECT_NAME=meshvault-m1
|
||||
MESHVAULT_HTTP_HOST=127.0.0.1
|
||||
MESHVAULT_HTTP_PORT=8090
|
||||
MESHVAULT_DB_NAME=meshvault
|
||||
MESHVAULT_DB_USER=meshvault
|
||||
MESHVAULT_DB_PASSWORD=
|
||||
# Dedicated managed destination only, never an original collection.
|
||||
# Host directory must already exist and be writable by container UID/GID 33.
|
||||
MESHVAULT_LIBRARY_HOST_DIR=./library
|
||||
# Absolute in-container path, outside /var/www/html; keep both paths distinct.
|
||||
MESHVAULT_LIBRARY_DIR=/var/lib/meshvault/library
|
||||
|
||||
# Application configuration (Task2 library; startup/Compose wiring is Task3).
|
||||
# Native PHP does NOT load this file: export variables in the shell or pass an
|
||||
# explicit string array to AppConfig::fromEnvironment(). Never commit secrets.
|
||||
# Defaults apply only when a key is absent; explicitly empty values are invalid.
|
||||
# Allowed: development, test, production. Default: production.
|
||||
MESHVAULT_ENV=development
|
||||
# PSR-3: debug, info, notice, warning, error, critical, alert, emergency. Default: info.
|
||||
MESHVAULT_LOG_LEVEL=info
|
||||
# Required DB_HOST/DB_NAME/DB_USER/DB_PASSWORD and LIBRARY_DIR have no defaults.
|
||||
# DB host/name/user must have no controls or surrounding whitespace.
|
||||
# Password is preserved verbatim; blank/whitespace-only or NUL is invalid.
|
||||
MESHVAULT_DB_HOST=db
|
||||
# Decimal digits 1..65535; default: 5432.
|
||||
MESHVAULT_DB_PORT=5432
|
||||
# LIBRARY_DIR must be absolute, existing and writable by the actual runtime user.
|
||||
# Validation never creates/chmods directories or opens a database connection.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Enforce LF line endings for all text files to avoid container CRLF issues.
|
||||
* text=auto eol=lf
|
||||
# Ensure shell entrypoints remain executable across Windows checkouts.
|
||||
docker/php/*.sh text eol=lf
|
||||
|
||||
+27
-12
@@ -1,8 +1,26 @@
|
||||
# Contributing to MeshVault
|
||||
# Contributing to MeshVault
|
||||
|
||||
## Setup and checks
|
||||
|
||||
Use PHP 8.3+ and Composer 2. Run from the repository root:
|
||||
Host requirements: Docker Compose v2+ with Linux containers. No host PHP,
|
||||
Composer or Node is required.
|
||||
|
||||
```sh
|
||||
# Start the stack (builds images if needed)
|
||||
./dev.sh up
|
||||
# Or on Windows: .\dev.ps1 up
|
||||
|
||||
# Run checks inside containers
|
||||
./dev.sh test
|
||||
./dev.sh lint
|
||||
|
||||
# Build assets deterministically
|
||||
./dev.sh assets
|
||||
# Watch during development
|
||||
./dev.sh watch
|
||||
```
|
||||
|
||||
If you have PHP 8.3+ and Composer 2 locally, you can also run:
|
||||
|
||||
```sh
|
||||
composer install --no-interaction
|
||||
@@ -11,17 +29,14 @@ composer lint
|
||||
composer test
|
||||
```
|
||||
|
||||
Commit `composer.lock` for reproducible development tooling. PHP_CodeSniffer is a
|
||||
development dependency, not an application framework. Composer scripts use PHP and
|
||||
local vendor binaries so they work in PowerShell as well as Unix shells.
|
||||
Commit both `composer.lock` and `package-lock.json` for reproducible builds.
|
||||
`migrate` and `seed` are unavailable until M2 (#12); they exit nonzero and leave
|
||||
#10 open. Never remove volumes with `down -v`; the wrapper forbids it.
|
||||
|
||||
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `db/migrations` and
|
||||
`tests`. `composer test` currently checks only repository foundations. Add behavior
|
||||
and regression tests with each implementation; the HTTP/database test framework
|
||||
will be selected with the application skeleton (#7), not assumed here.
|
||||
Docker, database migrations, Node/Tailwind builds and CI are not implemented yet.
|
||||
Do not use undocumented application commands or mistake foundation checks for
|
||||
integration coverage.
|
||||
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
|
||||
`templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
|
||||
and PHPUnit unit/integration tests. Add behavior and regression tests with each
|
||||
implementation.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
MeshVault is a self-hosted 3D model library being built as a Docker service.
|
||||
It catalogs logical models containing STL parts, images and supplementary documents.
|
||||
|
||||
**Status:** M0 foundations only. There is no runnable web application, database
|
||||
schema or Docker stack yet. Installation and a verified application quick start
|
||||
are deliverables of [M1](https://git.keithsolomon.net/keith/MeshVault/milestones).
|
||||
Do not deploy this scaffold as a service.
|
||||
**Status:** M1 development stack is up. A four-service Docker Compose stack serves
|
||||
a Slim 4 landing page with Tailwind CSS, configuration/logging, and cross-platform
|
||||
task wrappers. There is no catalog application, database schema or migration runner
|
||||
yet. See [the development stack setup](docker/README.md) for first-run instructions.
|
||||
Do not deploy this as a production service.
|
||||
|
||||
## Planned MVP
|
||||
|
||||
@@ -33,8 +34,10 @@ management, external imports, AI and multi-user libraries are outside MVP.
|
||||
## Architecture and decisions
|
||||
|
||||
Docker, PostgreSQL and PHP application logic; PHP-rendered templates with Tailwind
|
||||
CSS and browser JavaScript. Node is planned for asset builds, with mesh processing
|
||||
introduced only when those future features are scoped. No PHP framework is selected.
|
||||
CSS and browser JavaScript. Node runs inside an assets container for deterministic
|
||||
locked builds and watch; no host Node is required. Slim 4, PHP-DI and Monolog are
|
||||
installed, locked and wired. HTTP, configuration, logging and task wrappers are
|
||||
verified on the remote development host.
|
||||
|
||||
Accepted decisions: [managed storage (#1)](https://git.keithsolomon.net/keith/MeshVault/issues/1),
|
||||
[dates (#2)](https://git.keithsolomon.net/keith/MeshVault/issues/2),
|
||||
@@ -49,7 +52,7 @@ Local planning notes and ADR copies are deliberately not versioned.
|
||||
| `src/` | PHP application and domain code |
|
||||
| `public/` | Future HTTP document root and generated browser assets |
|
||||
| `db/migrations/` | Ordered PostgreSQL schema migrations |
|
||||
| `docker/` | Future container definitions and service configuration |
|
||||
| `docker/` | Development container definitions, configuration and setup |
|
||||
| `assets/` | Source assets, including the project icon |
|
||||
| `tests/` | Automated checks and future application fixtures/tests |
|
||||
|
||||
@@ -57,18 +60,51 @@ Directory READMEs describe boundaries; they are not application implementations.
|
||||
|
||||
## Development checks
|
||||
|
||||
With PHP 8.3+ and Composer 2 installed:
|
||||
The only host prerequisite is Docker Compose v2+ with Linux containers. All PHP
|
||||
and Node tooling runs inside containers.
|
||||
|
||||
```sh
|
||||
composer install --no-interaction
|
||||
composer validate --strict
|
||||
composer lint
|
||||
composer test
|
||||
# Start the stack (builds images if needed)
|
||||
./dev.sh up
|
||||
# Or on Windows: .\dev.ps1 up
|
||||
|
||||
# Run checks inside the app container
|
||||
./dev.sh test
|
||||
./dev.sh lint
|
||||
|
||||
# Build minified assets inside the assets container
|
||||
./dev.sh assets
|
||||
# Watch for template changes
|
||||
./dev.sh watch
|
||||
```
|
||||
|
||||
These commands validate foundation files and PSR-12 configuration, not application
|
||||
behavior. See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and test limitations.
|
||||
Container-based development, migrations and frontend build commands arrive in M1.
|
||||
`composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
|
||||
`composer audit` checks locked PHP dependencies. `migrate` and `seed` are unavailable
|
||||
until M2 (#12); they exit nonzero and leave #10 open.
|
||||
|
||||
Asset builds use a locked `package-lock.json` inside a container; host Node is not
|
||||
required. The generated `public/build/app.css` is served statically by nginx.
|
||||
|
||||
### Configuration and logging library
|
||||
|
||||
`MeshVault\Config\AppConfig::fromEnvironment(array $environment)` accepts explicit
|
||||
string values; supported keys, defaults and validation rules are in `.env.example`.
|
||||
Native PHP does not read `.env`: export the variables through your shell/process
|
||||
manager, then pass `getenv()` to the factory after requiring `vendor/autoload.php`.
|
||||
Compose environment injection/startup validation will be wired in Task3. Validate as
|
||||
the actual runtime user, not root. The library never creates or chmods library roots.
|
||||
|
||||
Readonly properties: `environment`, `libraryPath`, `dbHost`, `dbPort`, `dbName`,
|
||||
`dbUser`, `dbPassword`, `logLevel`. Configuration JSON/debug/serialization is redacted;
|
||||
serialized configuration cannot be restored. Read credentials only at their point of
|
||||
use: do not cast configuration to arrays or log credentials/messages containing them.
|
||||
This is not a general-purpose sanitizer for arbitrary caller-supplied secrets.
|
||||
|
||||
`MeshVault\Logging\LoggerFactory::create($config)` returns a PSR-3 logger writing
|
||||
newline-delimited JSON to stdout at the configured level. `forJob($logger, $jobId)`
|
||||
returns an independent scope; its `job_id` takes precedence over caller context,
|
||||
while other context is retained. Re-scoping replaces the job ID; the base logger
|
||||
and other jobs are unchanged. FPM forwarding remains a Task3 verification gate.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../../templates/**/*.php";
|
||||
@source "../../public/**/*.php";
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$stage = 'autoload';
|
||||
try {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$stage = 'configuration';
|
||||
\MeshVault\Config\AppConfig::fromEnvironment(getenv());
|
||||
fwrite(STDOUT, "Configuration valid for runtime user.\n");
|
||||
} catch (\Throwable $exception) {
|
||||
$detail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
|
||||
? $exception->getMessage() : 'Install dependencies and check bootstrap files.';
|
||||
fwrite(STDERR, 'Configuration check failed: ' . $detail . "\n");
|
||||
exit(1);
|
||||
}
|
||||
+18
-2
@@ -4,14 +4,30 @@
|
||||
"type": "project",
|
||||
"license": "AGPL-3.0-only",
|
||||
"require": {
|
||||
"php": ">=8.3"
|
||||
"php": ">=8.3",
|
||||
"monolog/monolog": "^3.12",
|
||||
"php-di/php-di": "^7.1",
|
||||
"psr/log": "^3.0",
|
||||
"slim/psr7": "^1.8",
|
||||
"slim/slim": "^4.15.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"squizlabs/php_codesniffer": "^3.13.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MeshVault\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"MeshVault\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "phpcs --standard=phpcs.xml.dist",
|
||||
"test": "@php tests/foundation.php"
|
||||
"test": ["@php tests/foundation.php", "@php vendor/phpunit/phpunit/phpunit"]
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
|
||||
Generated
+2683
-2
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MeshVault\Config\AppConfig;
|
||||
use MeshVault\Http\View;
|
||||
use MeshVault\Logging\LoggerFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
return static fn (AppConfig $config): array => [
|
||||
AppConfig::class => $config,
|
||||
View::class => new View(dirname(__DIR__) . '/templates'),
|
||||
LoggerInterface::class => static fn (): LoggerInterface => LoggerFactory::create($config),
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use MeshVault\Http\HomeHandler;
|
||||
use Slim\App;
|
||||
|
||||
return static function (App $app): void {
|
||||
$app->get('/', HomeHandler::class);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
# Deliberately use $args: unknown switches belong to the underlying command.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Command = if ($args.Count) { $args[0] } else { '' }
|
||||
$Forward = @($args | Select-Object -Skip 1)
|
||||
try {
|
||||
switch ($Command) {
|
||||
{ $_ -in 'migrate', 'seed' } {
|
||||
[Console]::Error.WriteLine("$Command unavailable until M2 (#12); #10 remains open")
|
||||
exit 1
|
||||
}
|
||||
'up' { $ComposeArgs = @('up', '-d', '--build') }
|
||||
'down' {
|
||||
foreach ($Argument in $Forward) {
|
||||
if ($Argument -match '^--volumes|^-v$') {
|
||||
[Console]::Error.WriteLine('Volume removal is forbidden by this wrapper')
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
$ComposeArgs = @('down')
|
||||
}
|
||||
{ $_ -in 'test', 'lint' } {
|
||||
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'composer', 'app', $Command)
|
||||
}
|
||||
'assets' { $ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'build', '--') }
|
||||
{ $_ -in 'watch', 'assets:watch' } {
|
||||
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'watch', '--')
|
||||
}
|
||||
default {
|
||||
[Console]::Error.WriteLine('Usage: dev.ps1 up|down|test|lint|assets|watch|migrate|seed [arguments]')
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
Push-Location $PSScriptRoot
|
||||
try {
|
||||
& docker compose @ComposeArgs @Forward
|
||||
$Result = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
exit $Result
|
||||
} catch {
|
||||
[Console]::Error.WriteLine($_.Exception.Message)
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
|
||||
cmd=${1:-}
|
||||
if [ "$#" -gt 0 ]; then shift; fi
|
||||
case "$cmd" in
|
||||
migrate|seed) echo "$cmd unavailable until M2 (#12); #10 remains open" >&2; exit 1 ;;
|
||||
down)
|
||||
for arg do
|
||||
case "$arg" in
|
||||
--volumes*|-v) echo 'Volume removal is forbidden by this wrapper' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
exec docker compose down "$@" ;;
|
||||
up) exec docker compose up -d --build "$@" ;;
|
||||
test|lint) exec docker compose run --rm --no-deps -T --entrypoint composer app "$cmd" "$@" ;;
|
||||
assets) exec docker compose run --rm --no-deps -T assets npm run build -- "$@" ;;
|
||||
watch|assets:watch) exec docker compose run --rm --no-deps -T assets npm run watch -- "$@" ;;
|
||||
*) echo 'Usage: dev.sh up|down|test|lint|assets|watch|migrate|seed [arguments]' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.30-alpine
|
||||
ports:
|
||||
- "${MESHVAULT_HTTP_HOST:-127.0.0.1}:${MESHVAULT_HTTP_PORT:-8090}:80"
|
||||
volumes:
|
||||
- ./public:/var/www/html/public:ro
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
user: www-data
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
MESHVAULT_LIBRARY_DIR: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
|
||||
MESHVAULT_ENV: ${MESHVAULT_ENV:-production}
|
||||
MESHVAULT_LOG_LEVEL: ${MESHVAULT_LOG_LEVEL:-info}
|
||||
MESHVAULT_DB_HOST: ${MESHVAULT_DB_HOST:-db}
|
||||
MESHVAULT_DB_PORT: ${MESHVAULT_DB_PORT:-5432}
|
||||
MESHVAULT_DB_NAME: ${MESHVAULT_DB_NAME:-meshvault}
|
||||
MESHVAULT_DB_USER: ${MESHVAULT_DB_USER:-meshvault}
|
||||
MESHVAULT_DB_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
|
||||
volumes:
|
||||
- ./public:/var/www/html/public:ro
|
||||
- ./src:/var/www/html/src:ro
|
||||
- ./config:/var/www/html/config:ro
|
||||
- ./templates:/var/www/html/templates:ro
|
||||
- ./tests:/var/www/html/tests:ro
|
||||
- ./db:/var/www/html/db:ro
|
||||
- ./bin:/var/www/html/bin:ro
|
||||
- ./composer.json:/var/www/html/composer.json:ro
|
||||
- ./phpunit.xml.dist:/var/www/html/phpunit.xml.dist:ro
|
||||
- ./phpcs.xml.dist:/var/www/html/phpcs.xml.dist:ro
|
||||
- ./dev.sh:/var/www/html/dev.sh:ro
|
||||
- ./dev.ps1:/var/www/html/dev.ps1:ro
|
||||
- ./README.md:/var/www/html/README.md:ro
|
||||
- ./CONTRIBUTING.md:/var/www/html/CONTRIBUTING.md:ro
|
||||
- ./LICENSE:/var/www/html/LICENSE:ro
|
||||
- ./.editorconfig:/var/www/html/.editorconfig:ro
|
||||
- ./.dockerignore:/var/www/html/.dockerignore:ro
|
||||
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
|
||||
- ./docker:/var/www/html/docker:ro
|
||||
- ./assets:/var/www/html/assets:ro
|
||||
- type: bind
|
||||
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
|
||||
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
|
||||
bind:
|
||||
create_host_path: false
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17-bookworm
|
||||
environment:
|
||||
POSTGRES_DB: ${MESHVAULT_DB_NAME:-meshvault}
|
||||
POSTGRES_USER: ${MESHVAULT_DB_USER:-meshvault}
|
||||
POSTGRES_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
assets:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/assets/Dockerfile
|
||||
profiles: [tools]
|
||||
user: node
|
||||
working_dir: /workspace
|
||||
init: true
|
||||
volumes:
|
||||
- ./assets/css:/workspace/assets/css:ro
|
||||
- ./public:/workspace/public:ro
|
||||
- ./public/build:/workspace/public/build
|
||||
- ./templates:/workspace/templates:ro
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
+113
-6
@@ -1,7 +1,114 @@
|
||||
# Container configuration
|
||||
# Development containers (M1)
|
||||
|
||||
M1 issue #6 adds nginx, PHP-FPM, PostgreSQL and asset build services. There is no
|
||||
Compose file yet. Keep database data, managed library files and uploaded media
|
||||
persistent and outside the application source. Mount import sources read-only
|
||||
where possible. Node is asset tooling in MVP, not a required mesh-processing
|
||||
runtime. Never bake credentials or user collections into images.
|
||||
Four-service Docker Compose stack: nginx, unprivileged PHP-FPM, PostgreSQL and
|
||||
an assets container for Tailwind CSS builds. The application layer (Slim 4,
|
||||
PHP-DI, Monolog) is wired, but there is no catalog schema, migration runner or
|
||||
asset pipeline beyond Tailwind. Do not expose this stack to the Internet.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine/Desktop with Linux containers and Docker Compose v2+.
|
||||
- No host PHP, Composer or Node is required.
|
||||
|
||||
## First start
|
||||
|
||||
From the repository root, copy `.env.example` to `.env` and edit
|
||||
`MESHVAULT_DB_PASSWORD` to a private, nonempty value. Never commit `.env` or print
|
||||
expanded Compose configuration in shared logs; use `docker compose config --quiet`.
|
||||
|
||||
Create a **new dedicated** `library` directory (`mkdir library`). Do not use an
|
||||
existing collection. On Linux, give container UID/GID 33 ownership of that new
|
||||
empty directory, for example `sudo chown 33:33 ./library` and
|
||||
`sudo chmod 750 ./library`. Alternatively, after building, use the narrowly scoped
|
||||
one-off command below **only for this new empty directory**:
|
||||
|
||||
```sh
|
||||
docker compose build app
|
||||
docker compose run --rm --no-deps --user root app chown 33:33 /var/lib/meshvault/library
|
||||
docker compose config --quiet
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
Also ensure `public/build` is writable by the assets container user (`node`, UID
|
||||
1000). On Linux, run:
|
||||
|
||||
```sh
|
||||
mkdir -p public/build
|
||||
chown 1000:1000 public/build
|
||||
```
|
||||
|
||||
Docker Desktop bind permissions differ; verify writability using the runtime
|
||||
identity rather than assuming host chmod works. The stack never automatically
|
||||
creates or changes ownership of a configured library directory.
|
||||
|
||||
## Task wrappers
|
||||
|
||||
Use `dev.sh` (POSIX) or `dev.ps1` (PowerShell) from the repository root. They are
|
||||
independent of your current working directory and propagate exit codes.
|
||||
|
||||
```sh
|
||||
./dev.sh up # build and start services
|
||||
./dev.sh down # stop services (volume removal is forbidden)
|
||||
./dev.sh test # run composer test inside the app container
|
||||
./dev.sh lint # run composer lint inside the app container
|
||||
./dev.sh assets # build minified CSS inside the assets container
|
||||
./dev.sh watch # start Tailwind watch inside the assets container
|
||||
./dev.sh migrate # unavailable until M2 (#12); exits nonzero
|
||||
./dev.sh seed # unavailable until M2 (#12); exits nonzero
|
||||
```
|
||||
|
||||
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
|
||||
`pgdata` volume or the library bind.
|
||||
|
||||
## Asset builds
|
||||
|
||||
Assets are built inside the `assets` service using a locked `package-lock.json`.
|
||||
The generated `public/build/app.css` is served statically by nginx.
|
||||
|
||||
```sh
|
||||
./dev.sh assets
|
||||
```
|
||||
|
||||
Watch mode rebuilds on template changes:
|
||||
|
||||
```sh
|
||||
./dev.sh watch
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Open http://127.0.0.1:8090/ for the landing page. `docker compose exec app id`
|
||||
should report UID/GID 33. Check a disposable library write without becoming root:
|
||||
|
||||
```sh
|
||||
docker compose exec app php -r '$p = getenv("MESHVAULT_LIBRARY_DIR") . "/write-check"; if (file_put_contents($p, "ok") === false) { exit(1); } unlink($p);'
|
||||
```
|
||||
|
||||
`down` preserves the named `pgdata` volume and library bind. Changing database
|
||||
credentials in `.env` does not change credentials in an initialized PostgreSQL
|
||||
volume. Back up data before upgrades.
|
||||
|
||||
## Settings and boundaries
|
||||
|
||||
`.env.example` documents every current setting: Compose project, HTTP host/port,
|
||||
DB name/user/password, host library path and absolute container library path.
|
||||
The container library path must stay outside `/var/www/html` and must not overlap
|
||||
system paths. The only required non-default value is the database password.
|
||||
The default project is `meshvault-m1`; HTTP binds localhost:8090. Only nginx has a
|
||||
published port. PostgreSQL readiness gates app startup. FPM itself runs as
|
||||
`www-data`, not just its workers. nginx mounts only `public/` and executes only
|
||||
`/index.php`; source, credentials and library are not web mounts.
|
||||
|
||||
Source mounts are explicit and read-only. No host `vendor` or `node_modules` is
|
||||
mounted; dependencies are installed inside images from lockfiles. No Docker socket
|
||||
is mounted. Startup configuration validation is provided by the entrypoint script.
|
||||
|
||||
Image branches: PHP 8.3 FPM Bookworm (security support to December 2027), PostgreSQL
|
||||
17 Bookworm (support to November 2029), nginx 1.30 Alpine (stable), Node 24
|
||||
Bookworm slim (LTS). Tags are versioned but move with patch updates, not digest
|
||||
locks. PHP 8.3 compatibility is retained.
|
||||
|
||||
Remote verification uses only `/home/keith/Projects/MeshVault` after confirming
|
||||
HOME, explicit project `meshvault-m1`, and an explicit source-only copy manifest.
|
||||
Access from another machine via `ssh -L 8090:127.0.0.1:8090 keith@192.168.2.23`.
|
||||
Never attach original collections or change unrelated services.
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /workspace
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
RUN chown node:node /workspace
|
||||
USER node
|
||||
RUN npm ci --no-audit --no-fund
|
||||
CMD ["npm", "run", "build"]
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
index index.php;
|
||||
server_tokens off;
|
||||
autoindex off;
|
||||
|
||||
location ~ /\. {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# Only the front controller may execute; never arbitrary uploaded PHP.
|
||||
location = /index.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param HTTP_PROXY "";
|
||||
fastcgi_pass app:9000;
|
||||
}
|
||||
|
||||
location ~* \.php(?:/|$) {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM php:8.3-fpm-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \
|
||||
&& docker-php-ext-install mbstring dom \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
WORKDIR /var/www/html
|
||||
# Dependencies are Linux-native and rebuilt from the lock, never host-mounted.
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-interaction --prefer-dist --no-progress
|
||||
COPY bin ./bin
|
||||
COPY docker/php/entrypoint.sh /usr/local/bin/meshvault-entrypoint
|
||||
COPY docker/php/zz-meshvault.conf /usr/local/etc/php-fpm.d/zz-meshvault.conf
|
||||
RUN chmod 755 /usr/local/bin/meshvault-entrypoint
|
||||
RUN mkdir -p /var/www/html/.phpunit.cache && chown www-data:www-data /var/www/html/.phpunit.cache
|
||||
|
||||
# The entire FPM process runs as the same identity used for library checks.
|
||||
USER www-data
|
||||
ENTRYPOINT ["meshvault-entrypoint"]
|
||||
CMD ["php-fpm"]
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Image and Compose both select www-data; validation and FPM inherit this identity.
|
||||
if [ "$1" = "php-fpm" ]; then
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
echo 'Refusing to validate or serve FPM as root; use www-data.' >&2
|
||||
exit 1
|
||||
fi
|
||||
php /var/www/html/bin/check-config.php
|
||||
fi
|
||||
exec docker-php-entrypoint "$@"
|
||||
@@ -0,0 +1,7 @@
|
||||
[www]
|
||||
; Preserve explicit Compose settings and forward Monolog JSON without prefixes.
|
||||
clear_env = no
|
||||
catch_workers_output = yes
|
||||
decorate_workers_output = no
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_flag[display_errors] = off
|
||||
Generated
+1236
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "meshvault-assets",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --minify",
|
||||
"watch": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --watch=always"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="MeshVault">
|
||||
<description>MeshVault PHP coding standard: PSR-12.</description>
|
||||
<rule ref="PSR12"/>
|
||||
@@ -6,6 +6,9 @@
|
||||
<arg name="encoding" value="utf-8"/>
|
||||
<file>src</file>
|
||||
<file>public</file>
|
||||
<file>config</file>
|
||||
<file>bin</file>
|
||||
<file>templates</file>
|
||||
<file>db/migrations</file>
|
||||
<file>tests</file>
|
||||
</ruleset>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php" cacheDirectory=".phpunit.cache"
|
||||
failOnWarning="true" failOnRisky="true" failOnNotice="true" failOnDeprecation="true">
|
||||
<testsuites>
|
||||
<testsuite name="unit"><directory>tests/Unit</directory></testsuite>
|
||||
<testsuite name="http"><directory>tests/Http</directory></testsuite>
|
||||
<testsuite name="integration"><directory>tests/Integration</directory></testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// This boundary intentionally does not depend on autoloading, DI or templates.
|
||||
ini_set('display_errors', '0');
|
||||
$stage = 'autoload';
|
||||
try {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$stage = 'configuration';
|
||||
$config = \MeshVault\Config\AppConfig::fromEnvironment(getenv());
|
||||
$stage = 'application';
|
||||
\MeshVault\Http\AppFactory::create($config)->run();
|
||||
} catch (\Throwable $exception) {
|
||||
$safeDetail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
|
||||
? $exception->getMessage()
|
||||
: 'Check dependencies and application bootstrap at ' . $exception->getFile() . ':' . $exception->getLine();
|
||||
error_log((string) json_encode([
|
||||
'message' => 'Application bootstrap failed',
|
||||
'stage' => $stage,
|
||||
'detail' => $safeDetail,
|
||||
'exception_type' => $exception::class,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!doctype html><html lang="en"><meta charset="utf-8"><title>Internal Server Error</title>';
|
||||
echo '<h1>Internal Server Error</h1>';
|
||||
if (getenv('MESHVAULT_ENV') === 'development') {
|
||||
echo '<pre>' . htmlspecialchars($safeDetail, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</pre>';
|
||||
}
|
||||
echo '</html>';
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Config;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
use SensitiveParameter;
|
||||
|
||||
final readonly class AppConfig implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
public string $environment,
|
||||
public string $libraryPath,
|
||||
public string $dbHost,
|
||||
public int $dbPort,
|
||||
public string $dbName,
|
||||
public string $dbUser,
|
||||
#[SensitiveParameter] public string $dbPassword,
|
||||
public string $logLevel,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Values are explicit strings, as returned by getenv(); no implicit .env loading. */
|
||||
public static function fromEnvironment(#[SensitiveParameter] array $environment): self
|
||||
{
|
||||
$required = static function (string $key) use ($environment): string {
|
||||
$value = $environment[$key] ?? null;
|
||||
if (!is_string($value) || trim($value) === '' || str_contains($value, "\0")) {
|
||||
throw new InvalidArgumentException($key . ' must be a non-empty string without NUL bytes.');
|
||||
}
|
||||
return $value;
|
||||
};
|
||||
$choice = static function (string $key, string $default, array $choices) use ($environment): string {
|
||||
$value = array_key_exists($key, $environment) ? $environment[$key] : $default;
|
||||
if (!is_string($value) || !in_array($value, $choices, true)) {
|
||||
throw new InvalidArgumentException($key . ' must be one of: ' . implode(', ', $choices) . '.');
|
||||
}
|
||||
return $value;
|
||||
};
|
||||
$appEnvironment = $choice('MESHVAULT_ENV', 'production', ['development', 'test', 'production']);
|
||||
$level = $choice('MESHVAULT_LOG_LEVEL', 'info', [
|
||||
'debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency',
|
||||
]);
|
||||
$port = array_key_exists('MESHVAULT_DB_PORT', $environment) ? $environment['MESHVAULT_DB_PORT'] : '5432';
|
||||
if (!is_string($port) || !preg_match('/^[0-9]{1,5}$/D', $port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
throw new InvalidArgumentException('MESHVAULT_DB_PORT must be decimal digits in the range 1..65535.');
|
||||
}
|
||||
$path = $required('MESHVAULT_LIBRARY_DIR');
|
||||
$absolute = str_starts_with($path, '/')
|
||||
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^(?:[A-Za-z]:[\\\\/]|\\\\\\\\)~', $path));
|
||||
if (!$absolute || preg_match('/[\x00-\x1f\x7f]/', $path)) {
|
||||
throw new InvalidArgumentException('MESHVAULT_LIBRARY_DIR must be an absolute local directory path.');
|
||||
}
|
||||
clearstatcache(true, $path);
|
||||
if (!is_dir($path) || !is_writable($path)) {
|
||||
throw new InvalidArgumentException(
|
||||
'MESHVAULT_LIBRARY_DIR must exist as a directory writable by the runtime user.'
|
||||
);
|
||||
}
|
||||
$database = [];
|
||||
foreach (['MESHVAULT_DB_HOST', 'MESHVAULT_DB_NAME', 'MESHVAULT_DB_USER'] as $key) {
|
||||
$value = $required($key);
|
||||
if (trim($value) !== $value || preg_match('/[\x00-\x1f\x7f]/', $value)) {
|
||||
throw new InvalidArgumentException($key . ' must not contain controls or surrounding whitespace.');
|
||||
}
|
||||
$database[$key] = $value;
|
||||
}
|
||||
return new self(
|
||||
$appEnvironment,
|
||||
$path,
|
||||
$database['MESHVAULT_DB_HOST'],
|
||||
(int) $port,
|
||||
$database['MESHVAULT_DB_NAME'],
|
||||
$database['MESHVAULT_DB_USER'],
|
||||
$required('MESHVAULT_DB_PASSWORD'),
|
||||
$level,
|
||||
);
|
||||
}
|
||||
|
||||
/** Diagnostic representations intentionally contain no configuration values. */
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['configuration' => '[redacted]'];
|
||||
}
|
||||
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->jsonSerialize();
|
||||
}
|
||||
|
||||
public function __serialize(): array
|
||||
{
|
||||
return $this->jsonSerialize();
|
||||
}
|
||||
|
||||
public function __unserialize(array $data): void
|
||||
{
|
||||
throw new \LogicException('AppConfig must be created from explicit environment input.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use DI\ContainerBuilder;
|
||||
use MeshVault\Config\AppConfig;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\App;
|
||||
use Slim\Exception\HttpMethodNotAllowedException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Psr7\Response;
|
||||
use Throwable;
|
||||
|
||||
final class AppFactory
|
||||
{
|
||||
public static function create(AppConfig $config): App
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
|
||||
$builder->addDefinitions($definitions($config));
|
||||
$container = $builder->build();
|
||||
$app = \Slim\Factory\AppFactory::createFromContainer($container);
|
||||
$routes = require dirname(__DIR__, 2) . '/config/routes.php';
|
||||
$routes($app);
|
||||
$app->addRoutingMiddleware();
|
||||
$errors = $app->addErrorMiddleware($config->environment === 'development', true, false);
|
||||
$errors->setDefaultErrorHandler(function (
|
||||
ServerRequestInterface $request,
|
||||
Throwable $exception,
|
||||
bool $displayErrorDetails
|
||||
) use ($container): Response {
|
||||
$status = match (true) {
|
||||
$exception instanceof HttpNotFoundException => 404,
|
||||
$exception instanceof HttpMethodNotAllowedException => 405,
|
||||
default => 500,
|
||||
};
|
||||
$title = match ($status) {
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
default => 'Internal Server Error',
|
||||
};
|
||||
if ($status === 500) {
|
||||
// No exception messages, arguments, request data or config values in production logs.
|
||||
$container->get(LoggerInterface::class)->error('HTTP request failed', [
|
||||
'exception_type' => $exception::class,
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
$response = new Response($status);
|
||||
if ($exception instanceof HttpMethodNotAllowedException) {
|
||||
$response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods()));
|
||||
}
|
||||
$response->getBody()->write($container->get(View::class)->render('error', [
|
||||
'title' => $title,
|
||||
'detail' => $displayErrorDetails ? $exception::class . ': ' . $exception->getMessage() : '',
|
||||
]));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
});
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final readonly class HomeHandler
|
||||
{
|
||||
public function __construct(private View $view)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$response->getBody()->write($this->view->render('home', ['title' => 'MeshVault']));
|
||||
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Http;
|
||||
|
||||
final readonly class View
|
||||
{
|
||||
public function __construct(private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
public function render(string $template, array $data): string
|
||||
{
|
||||
if (!in_array($template, ['home', 'error'], true)) {
|
||||
throw new \InvalidArgumentException('Unknown view.');
|
||||
}
|
||||
$escape = static fn (string $value): string => htmlspecialchars(
|
||||
$value,
|
||||
ENT_QUOTES | ENT_SUBSTITUTE,
|
||||
'UTF-8'
|
||||
);
|
||||
ob_start();
|
||||
try {
|
||||
require $this->directory . '/' . $template . '.php';
|
||||
return (string) ob_get_contents();
|
||||
} finally {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Logging;
|
||||
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Stringable;
|
||||
|
||||
/** Immutable job scope; no mutable processor shared with another job. */
|
||||
final class JobLogger extends AbstractLogger
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LoggerInterface $logger,
|
||||
private readonly string $jobId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$context['job_id'] = $this->jobId;
|
||||
$this->logger->log($level, $message, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Logging;
|
||||
|
||||
use MeshVault\Config\AppConfig;
|
||||
use Monolog\Formatter\JsonFormatter;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class LoggerFactory
|
||||
{
|
||||
public static function create(AppConfig $config): LoggerInterface
|
||||
{
|
||||
$handler = new StreamHandler('php://stdout', $config->logLevel);
|
||||
$handler->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true));
|
||||
return new Logger('meshvault', [$handler]);
|
||||
}
|
||||
|
||||
public static function forJob(LoggerInterface $logger, string $jobId): LoggerInterface
|
||||
{
|
||||
// Re-scoping an existing job replaces its ID without mutating either logger.
|
||||
if ($logger instanceof JobLogger) {
|
||||
$logger = $logger->logger;
|
||||
}
|
||||
return new JobLogger($logger, $jobId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title><?= $escape($data['title']) ?></title></head>
|
||||
<body><h1><?= $escape($data['title']) ?></h1><pre><?= $escape($data['detail']) ?></pre></body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= $escape($data['title']) ?></title>
|
||||
<link rel="stylesheet" href="/build/app.css">
|
||||
</head>
|
||||
<body><h1 class="text-blue-600"><?= $escape($data['title']) ?></h1><p>Your managed 3D model library.</p></body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Http;
|
||||
|
||||
use MeshVault\Config\AppConfig;
|
||||
use MeshVault\Http\AppFactory;
|
||||
use MeshVault\Http\HomeHandler;
|
||||
use MeshVault\Http\View;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
final class AppTest extends TestCase
|
||||
{
|
||||
private function app(string $environment = 'production'): \Slim\App
|
||||
{
|
||||
self::assertTrue(class_exists(AppFactory::class), 'HTTP AppFactory must exist');
|
||||
return AppFactory::create(AppConfig::fromEnvironment([
|
||||
'MESHVAULT_ENV' => $environment,
|
||||
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
|
||||
'MESHVAULT_DB_HOST' => 'db',
|
||||
'MESHVAULT_DB_NAME' => 'meshvault',
|
||||
'MESHVAULT_DB_USER' => 'meshvault',
|
||||
'MESHVAULT_DB_PASSWORD' => 'private-secret',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testHomeUsesContainerResolvedHandlerAndHtmlView(): void
|
||||
{
|
||||
$app = $this->app();
|
||||
self::assertInstanceOf(\DI\Container::class, $app->getContainer());
|
||||
self::assertInstanceOf(HomeHandler::class, $app->getContainer()->get(HomeHandler::class));
|
||||
$app->getContainer()->set(View::class, new View(dirname(__DIR__, 2) . '/templates'));
|
||||
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/'));
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
|
||||
self::assertStringContainsString('<h1 class="text-blue-600">MeshVault</h1>', (string) $response->getBody());
|
||||
self::assertSame(HomeHandler::class, array_values($app->getRouteCollector()->getRoutes())[0]->getCallable());
|
||||
}
|
||||
|
||||
public function testViewEscapesDynamicTextAndQuotes(): void
|
||||
{
|
||||
self::assertTrue(class_exists(View::class), 'PHP View must exist');
|
||||
$html = (new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => '<script>"&\'</script>']);
|
||||
self::assertStringContainsString('<script>"&'</script>', $html);
|
||||
self::assertStringNotContainsString('<script>', $html);
|
||||
}
|
||||
|
||||
public function testViewRejectsTraversalBeforeStartingOutputBuffer(): void
|
||||
{
|
||||
$level = ob_get_level();
|
||||
try {
|
||||
(new View(dirname(__DIR__, 2) . '/templates'))->render('../config/container', []);
|
||||
self::fail('Traversal must be rejected');
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
self::assertSame('Unknown view.', $exception->getMessage());
|
||||
self::assertSame($level, ob_get_level());
|
||||
}
|
||||
}
|
||||
|
||||
public function testViewRestoresOutputBufferWhenTemplateThrows(): void
|
||||
{
|
||||
$level = ob_get_level();
|
||||
try {
|
||||
(new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => new \stdClass()]);
|
||||
self::fail('Invalid view value must throw');
|
||||
} catch (\TypeError) {
|
||||
self::assertSame($level, ob_get_level());
|
||||
}
|
||||
}
|
||||
|
||||
public function testNotFound(): void
|
||||
{
|
||||
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('GET', '/missing'));
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('Not Found', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testMethodNotAllowed(): void
|
||||
{
|
||||
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('POST', '/'));
|
||||
self::assertSame(405, $response->getStatusCode());
|
||||
self::assertStringContainsString('GET', $response->getHeaderLine('Allow'));
|
||||
}
|
||||
|
||||
public function testProductionExceptionIsGeneric(): void
|
||||
{
|
||||
$app = $this->app();
|
||||
$app->get('/test-only', function (): never {
|
||||
throw new \RuntimeException('private-secret <script>failure</script>');
|
||||
});
|
||||
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
|
||||
self::assertSame(500, $response->getStatusCode());
|
||||
self::assertStringContainsString('Internal Server Error', (string) $response->getBody());
|
||||
self::assertStringNotContainsString('private-secret', (string) $response->getBody());
|
||||
self::assertStringNotContainsString('AppTest', (string) $response->getBody());
|
||||
}
|
||||
|
||||
public function testDevelopmentExceptionIsReadableAndEscaped(): void
|
||||
{
|
||||
$app = $this->app('development');
|
||||
$app->get('/test-only', function (): never {
|
||||
throw new \RuntimeException('Readable <script>failure</script>');
|
||||
});
|
||||
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
|
||||
self::assertSame(500, $response->getStatusCode());
|
||||
$body = (string) $response->getBody();
|
||||
self::assertStringContainsString('Readable <script>failure</script>', $body);
|
||||
self::assertStringNotContainsString('<script>', (string) $response->getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Http;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BootstrapTest extends TestCase
|
||||
{
|
||||
private function runPhp(string $file, string $mode, bool $valid): array
|
||||
{
|
||||
$env = array_merge(getenv(), [
|
||||
'MESHVAULT_ENV' => $mode,
|
||||
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
|
||||
'MESHVAULT_DB_HOST' => $valid ? 'db' : '',
|
||||
'MESHVAULT_DB_NAME' => 'meshvault',
|
||||
'MESHVAULT_DB_USER' => 'meshvault',
|
||||
'MESHVAULT_DB_PASSWORD' => 'bootstrap-secret',
|
||||
]);
|
||||
$code = 'register_shutdown_function(function () { fwrite(STDERR, "STATUS=" . http_response_code()); });'
|
||||
. 'require ' . var_export($file, true) . ';';
|
||||
$process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return [proc_close($process), $out, $err];
|
||||
}
|
||||
|
||||
public function testProductionBootstrapFailureIsGeneric500(): void
|
||||
{
|
||||
[$exit, $out, $err] = $this->runPhp('public/index.php', 'production', false);
|
||||
self::assertStringContainsString('Internal Server Error', $out);
|
||||
self::assertStringContainsString('STATUS=500', $err);
|
||||
self::assertStringNotContainsString('MESHVAULT_DB_HOST', $out);
|
||||
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
|
||||
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
|
||||
}
|
||||
|
||||
public function testDevelopmentBootstrapFailureIsReadable(): void
|
||||
{
|
||||
[$exit, $out, $err] = $this->runPhp('public/index.php', 'development', false);
|
||||
self::assertStringContainsString('MESHVAULT_DB_HOST', $out);
|
||||
self::assertStringContainsString('STATUS=500', $err);
|
||||
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
|
||||
}
|
||||
|
||||
public function testConfigCliSucceedsOnlyForValidConfig(): void
|
||||
{
|
||||
self::assertFileExists('bin/check-config.php');
|
||||
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', true);
|
||||
self::assertSame(0, $exit, $err);
|
||||
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', false);
|
||||
self::assertSame(1, $exit);
|
||||
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
|
||||
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Http;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DeploymentTest extends TestCase
|
||||
{
|
||||
public function testFpmConfigExistsAndForwardsUndecoratedWorkerLogs(): void
|
||||
{
|
||||
$path = dirname(__DIR__, 2) . '/docker/php/zz-meshvault.conf';
|
||||
self::assertFileExists($path);
|
||||
$config = file_get_contents($path);
|
||||
self::assertStringContainsString('clear_env = no', $config);
|
||||
self::assertStringContainsString('catch_workers_output = yes', $config);
|
||||
self::assertStringContainsString('decorate_workers_output = no', $config);
|
||||
}
|
||||
|
||||
public function testComposeSuppliesEveryApplicationSettingAndViewDirectory(): void
|
||||
{
|
||||
$compose = file_get_contents(dirname(__DIR__, 2) . '/docker-compose.yml');
|
||||
$app = explode(' db:', explode(' app:', $compose, 2)[1], 2)[0];
|
||||
foreach (['ENV', 'LOG_LEVEL', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'] as $key) {
|
||||
self::assertStringContainsString('MESHVAULT_' . $key . ':', $app);
|
||||
}
|
||||
foreach (['config', 'templates'] as $directory) {
|
||||
self::assertStringContainsString('./' . $directory . ':/var/www/html/' . $directory . ':ro', $app);
|
||||
}
|
||||
}
|
||||
|
||||
public function testBuildContextIncludesDependencyAndStartupInputs(): void
|
||||
{
|
||||
$ignore = file_get_contents(dirname(__DIR__, 2) . '/.dockerignore');
|
||||
foreach (['composer.json', 'composer.lock', 'bin/', 'bin/**', 'docker/php/**'] as $input) {
|
||||
self::assertStringContainsString('!' . $input, $ignore);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TaskRunnerTest extends TestCase
|
||||
{
|
||||
public function testCommandsFromAnotherWorkingDirectory(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
$temporary = sys_get_temp_dir() . '/meshvault-runner-' . bin2hex(random_bytes(6));
|
||||
mkdir($temporary);
|
||||
$windows = PHP_OS_FAMILY === 'Windows';
|
||||
$fake = $temporary . ($windows ? '/docker.cmd' : '/docker');
|
||||
file_put_contents($fake, $windows
|
||||
? "@echo off\r\necho cwd=%CD%\r\necho %*\r\nexit /b 37\r\n"
|
||||
: "#!/bin/sh\nprintf 'cwd=%s\\n' \"\$PWD\"\nprintf '%s\\n' \"\$@\"\nexit 37\n");
|
||||
chmod($fake, 0755);
|
||||
$environment = getenv();
|
||||
$environment[$windows ? 'Path' : 'PATH'] = $temporary . PATH_SEPARATOR . getenv('PATH');
|
||||
$runner = $windows
|
||||
? ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $root . '/dev.ps1']
|
||||
: ['sh', $root . '/dev.sh'];
|
||||
try {
|
||||
foreach (['up', 'down', 'test', 'lint', 'assets', 'watch'] as $command) {
|
||||
[$code, $output] = $this->invoke(
|
||||
[...$runner, $command, '--probe', 'two words'],
|
||||
$temporary,
|
||||
$environment
|
||||
);
|
||||
self::assertSame(37, $code, $command . ': ' . $output);
|
||||
self::assertStringContainsString('compose', $output);
|
||||
self::assertStringContainsString('--probe', $output);
|
||||
self::assertStringContainsString('two words', $output);
|
||||
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
|
||||
}
|
||||
foreach (['unknown', 'migrate', 'seed'] as $command) {
|
||||
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
|
||||
self::assertNotSame(0, $code);
|
||||
self::assertStringNotContainsString('cwd=', $output);
|
||||
}
|
||||
foreach (['-v', '--volumes', '--volumes=true'] as $flag) {
|
||||
[$code, $output] = $this->invoke([...$runner, 'down', $flag], $temporary, $environment);
|
||||
self::assertNotSame(0, $code);
|
||||
self::assertStringNotContainsString('cwd=', $output);
|
||||
}
|
||||
} finally {
|
||||
unlink($fake);
|
||||
rmdir($temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private function invoke(array $command, string $cwd, array $environment): array
|
||||
{
|
||||
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, $environment);
|
||||
self::assertIsResource($process);
|
||||
$output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return [proc_close($process), $output];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Unit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use MeshVault\Config\AppConfig;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConfigTest extends TestCase
|
||||
{
|
||||
public static function environment(): array
|
||||
{
|
||||
return [
|
||||
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
|
||||
'MESHVAULT_DB_HOST' => 'db',
|
||||
'MESHVAULT_DB_NAME' => 'meshvault',
|
||||
'MESHVAULT_DB_USER' => 'meshvault',
|
||||
'MESHVAULT_DB_PASSWORD' => ' private-secret ',
|
||||
];
|
||||
}
|
||||
|
||||
public function testDefaultsAndImmutableExplicitInput(): void
|
||||
{
|
||||
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
|
||||
$config = AppConfig::fromEnvironment(self::environment());
|
||||
self::assertSame('production', $config->environment);
|
||||
self::assertSame('info', $config->logLevel);
|
||||
self::assertSame(5432, $config->dbPort);
|
||||
self::assertSame(sys_get_temp_dir(), $config->libraryPath);
|
||||
self::assertSame('db', $config->dbHost);
|
||||
self::assertSame('meshvault', $config->dbName);
|
||||
self::assertSame('meshvault', $config->dbUser);
|
||||
self::assertSame(' private-secret ', $config->dbPassword);
|
||||
self::assertTrue((new \ReflectionClass($config))->isReadOnly());
|
||||
$this->expectException(\Error::class);
|
||||
$config->dbPort = 1234;
|
||||
}
|
||||
|
||||
public static function invalidValues(): iterable
|
||||
{
|
||||
foreach (array_keys(self::environment()) as $key) {
|
||||
foreach ([null, '', ' ', false, 42, [], new \stdClass()] as $value) {
|
||||
yield [$key, $value];
|
||||
}
|
||||
}
|
||||
foreach (['0', '65536', '-1', '+5432', '1.2', '1e3', ' 5432', '5432x', '', null, 5432] as $port) {
|
||||
yield ['MESHVAULT_DB_PORT', $port];
|
||||
}
|
||||
foreach (['', 'prod', 'Production', 'development ', null, []] as $env) {
|
||||
yield ['MESHVAULT_ENV', $env];
|
||||
}
|
||||
foreach (['', 'verbose', 'INFO', null, false] as $level) {
|
||||
yield ['MESHVAULT_LOG_LEVEL', $level];
|
||||
}
|
||||
foreach ([__FILE__, __DIR__ . '/absent-library', 'relative/path', "bad\0path"] as $path) {
|
||||
yield ['MESHVAULT_LIBRARY_DIR', $path];
|
||||
}
|
||||
yield ['MESHVAULT_DB_HOST', "db\nsecret"];
|
||||
yield ['MESHVAULT_DB_NAME', "name\0secret"];
|
||||
yield ['MESHVAULT_DB_USER', ' user '];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidValues')]
|
||||
public function testInvalidValuesAreRejectedWithoutEchoingInput(string $key, mixed $value): void
|
||||
{
|
||||
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
|
||||
$environment = self::environment();
|
||||
$environment[$key] = $value;
|
||||
try {
|
||||
AppConfig::fromEnvironment($environment);
|
||||
self::fail('Expected invalid configuration: ' . $key);
|
||||
} catch (InvalidArgumentException $error) {
|
||||
self::assertStringContainsString($key, $error->getMessage());
|
||||
self::assertStringNotContainsString('private-secret', (string) $error);
|
||||
if (is_string($value) && strlen($value) > 5) {
|
||||
self::assertStringNotContainsString($value, $error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRequiredKeysMustBePresent(): void
|
||||
{
|
||||
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
|
||||
foreach (array_keys(self::environment()) as $key) {
|
||||
$environment = self::environment();
|
||||
unset($environment[$key]);
|
||||
try {
|
||||
AppConfig::fromEnvironment($environment);
|
||||
self::fail('Missing key accepted');
|
||||
} catch (InvalidArgumentException $error) {
|
||||
self::assertStringContainsString($key, $error->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testSupportedEnvironmentsLevelsAndPortBoundaries(): void
|
||||
{
|
||||
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
|
||||
foreach (['development', 'test', 'production'] as $env) {
|
||||
foreach (['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'] as $level) {
|
||||
foreach (['1', '65535'] as $port) {
|
||||
$config = AppConfig::fromEnvironment(self::environment() + [
|
||||
'MESHVAULT_ENV' => $env, 'MESHVAULT_LOG_LEVEL' => $level, 'MESHVAULT_DB_PORT' => $port,
|
||||
]);
|
||||
self::assertSame($env, $config->environment);
|
||||
self::assertSame($level, $config->logLevel);
|
||||
self::assertSame((int) $port, $config->dbPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDiagnosticsAndSerializationDoNotExposePassword(): void
|
||||
{
|
||||
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
|
||||
$config = AppConfig::fromEnvironment(self::environment());
|
||||
self::assertStringNotContainsString('private-secret', json_encode($config, JSON_THROW_ON_ERROR));
|
||||
self::assertStringNotContainsString('private-secret', print_r($config, true));
|
||||
self::assertStringNotContainsString('private-secret', serialize($config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Unit;
|
||||
|
||||
use MeshVault\Logging\LoggerFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Stringable;
|
||||
|
||||
final class LoggingTest extends TestCase
|
||||
{
|
||||
public function testJsonStdoutFilteringAndIsolatedJobContext(): void
|
||||
{
|
||||
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
|
||||
$code = <<<'PHP'
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
$config = \MeshVault\Config\AppConfig::fromEnvironment([
|
||||
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
|
||||
'MESHVAULT_DB_HOST' => 'db',
|
||||
'MESHVAULT_DB_NAME' => 'meshvault',
|
||||
'MESHVAULT_DB_USER' => 'meshvault',
|
||||
'MESHVAULT_DB_PASSWORD' => 'private-secret',
|
||||
'MESHVAULT_LOG_LEVEL' => 'warning',
|
||||
]);
|
||||
$base = \MeshVault\Logging\LoggerFactory::create($config);
|
||||
$a = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-a');
|
||||
$b = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-b');
|
||||
$base->info('filtered');
|
||||
$a->debug('filtered job');
|
||||
$a->warning("first\nline", ['file' => 'part.stl', 'job_id' => 'spoofed', 'config' => $config]);
|
||||
$b->error('second', ['count' => 2]);
|
||||
$base->warning('base');
|
||||
$a->critical('again');
|
||||
\MeshVault\Logging\LoggerFactory::forJob($a, 'job-c')->alert('nested');
|
||||
PHP;
|
||||
$process = proc_open([PHP_BINARY], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
self::assertIsResource($process);
|
||||
fwrite($pipes[0], $code);
|
||||
fclose($pipes[0]);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
self::assertSame(0, proc_close($process), $stderr);
|
||||
self::assertSame('', $stderr);
|
||||
self::assertStringNotContainsString('private-secret', $stdout);
|
||||
self::assertStringNotContainsString('filtered', $stdout);
|
||||
$lines = explode("\n", rtrim($stdout, "\r\n"));
|
||||
self::assertCount(5, $lines);
|
||||
$records = array_map(
|
||||
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
|
||||
$lines,
|
||||
);
|
||||
self::assertSame("first\nline", $records[0]['message']);
|
||||
self::assertSame('WARNING', $records[0]['level_name']);
|
||||
self::assertSame('job-a', $records[0]['context']['job_id']);
|
||||
self::assertSame('part.stl', $records[0]['context']['file']);
|
||||
self::assertSame('job-b', $records[1]['context']['job_id']);
|
||||
self::assertSame(2, $records[1]['context']['count']);
|
||||
self::assertArrayNotHasKey('job_id', $records[2]['context']);
|
||||
self::assertSame('job-a', $records[3]['context']['job_id']);
|
||||
self::assertSame('job-c', $records[4]['context']['job_id']);
|
||||
self::assertArrayHasKey('datetime', $records[0]);
|
||||
}
|
||||
|
||||
public function testJobScopeWorksWithAnyPsrLoggerAndRetainsMessage(): void
|
||||
{
|
||||
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
|
||||
$base = new class extends AbstractLogger {
|
||||
public array $records = [];
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->records[] = [$level, $message, $context];
|
||||
}
|
||||
};
|
||||
$message = new class implements Stringable {
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'message';
|
||||
}
|
||||
};
|
||||
LoggerFactory::forJob($base, 'job-a')->notice($message, ['file' => 'part.stl']);
|
||||
self::assertSame(['notice', $message, ['file' => 'part.stl', 'job_id' => 'job-a']], $base->records[0]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user