32declare(strict_types=1);
33require dirname(__DIR__).
'/tools/deploy.php';
34$options = getopt(
'', [
'driver:',
'real-composer']);
36if (!in_array(
$driver, [
'mysql',
'pgsql'],
true)) {
throw new InvalidArgumentException(
'Choose mysql or pgsql.'); }
41function deployCheck($expected, $actual,
string $label): void {
44 if ($expected !== $actual) {
throw new RuntimeException($label.
' failed.'); }
51 [
'https://example.com',
'timezone',
'https://example.com/timezone/'],
52 [
'https://example.com/',
'timezones',
'https://example.com/timezones/'],
53 [
'https://recovrr.org/recovrr/timezones',
'timezone',
'https://recovrr.org/recovrr/timezones/timezone/'],
54 [
'https://recovrr.org/recovrr/timezones/',
'timezone',
'https://recovrr.org/recovrr/timezones/timezone/'],
55 [
'http://localhost:8080/services',
'timezone',
'http://localhost:8080/services/timezone/'],
56 [
'https://example.com/a%20directory/',
'tz_service-2',
'https://example.com/a%20directory/tz_service-2/'],
57] as [$webUrl, $endpoint, $expected]) {
58 deployCheck($expected,
deployServiceUrl($webUrl, $endpoint),
'Preserve the public directory URL and append one service subdirectory');
60foreach ([
'',
'/timezone',
'recovrr.org/recovrr',
'ftp://example.com',
'https://user:password@example.com',
61 'https://example.com/?secret=old',
'https://example.com/#section',
'https://example.com/a b',
62 'https://example.com/path\\other'] as $webUrl) {
63 try {
deployServiceUrl($webUrl,
'timezone');
throw new RuntimeException(
'Expected web URL refusal.'); }
64 catch (InvalidArgumentException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'full http://'),
'Reject an ambiguous or unsuitable web directory URL'); }
67$name =
'lgv_tz_deploy_test_'.bin2hex(random_bytes(8));
68$settings = [
'driver' =>
$driver,
'host' => getenv(
'LGV_TZ_TEST_HOST') ?:
'localhost',
69 'port' => (int)(getenv(
'LGV_TZ_TEST_PORT') ?: (
$driver ===
'pgsql' ? 5432 : 3306)),
70 'database' =>
$name,
'user' => getenv(
'LGV_TZ_TEST_USER') ?: (
$driver ===
'pgsql' ? (getenv(
'USER') ?: get_current_user()) :
'root'),
71 'password' => getenv(
'LGV_TZ_TEST_PASSWORD') ?:
'',
'admin_database' => getenv(
'LGV_TZ_TEST_ADMIN_DATABASE') ?:
'postgres'];
74$work = sys_get_temp_dir().
'/lgv-tz-deploy-test-'.bin2hex(random_bytes(8));
78 foreach ([
'before-publication',
'interrupted',
'interrupted-before-stage',
'interrupted-both',
79 'published',
'new-file',
'public-new-file',
'changed-identity'] as $scenario) {
80 $caseWork =
$work.
'/empty-'.$scenario;
81 mkdir($caseWork, 0700);
82 $privateTarget = $caseWork.
'/private'; $publicTarget = $caseWork.
'/public';
83 mkdir($privateTarget, 0750); mkdir($publicTarget, 0700);
85 deployCheck([
'private',
'public'], array_keys($empty),
'Recognize pre-existing empty destinations');
86 $token = bin2hex(random_bytes(32));
87 $directoryState = [
'private' => $privateTarget,
'public' => $publicTarget,
88 'private_stage' => $caseWork.
'/private-stage',
'public_stage' => $caseWork.
'/public-stage',
89 'token' => $token,
'published' =>
false,
'empty_directories' => $empty];
90 foreach ([
'private_stage',
'public_stage'] as $key) {
91 mkdir($directoryState[$key], 0700);
92 file_put_contents($directoryState[$key].
'/.lgv-tz-install-owner', $token);
93 file_put_contents($directoryState[$key].
'/fixture.txt',
'installed file');
96 $privatePermissions = fileperms($privateTarget) & 0777;
97 $privateOwner = fileowner($privateTarget); $privateGroup = filegroup($privateTarget);
98 if ($scenario ===
'interrupted') {
100 rename($privateTarget, $empty[
'private'][
'backup']);
101 rename($directoryState[
'private_stage'], $privateTarget);
102 } elseif ($scenario ===
'interrupted-before-stage') {
103 rename($privateTarget, $empty[
'private'][
'backup']);
104 } elseif ($scenario ===
'interrupted-both') {
106 } elseif ($scenario ===
'published') {
108 $directoryState[
'published'] =
true;
110 } elseif ($scenario ===
'new-file' || $scenario ===
'public-new-file') {
111 $noteTarget = $scenario ===
'new-file' ? $privateTarget : $publicTarget;
112 file_put_contents($noteTarget.
'/.operator-note',
'keep this new file');
113 try {
deployPublishDirectories($directoryState);
throw new RuntimeException(
'Expected changed-directory refusal.'); }
114 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'changed during'),
'Refuse publication after a hidden file appears'); }
115 } elseif ($scenario ===
'changed-identity') {
116 rename($privateTarget, $caseWork.
'/original-private');
117 mkdir($privateTarget, 0700);
118 try {
deployPublishDirectories($directoryState);
throw new RuntimeException(
'Expected replaced-directory refusal.'); }
119 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'changed during'),
'Refuse an empty directory replaced during installation'); }
121 try {
deployCleanup($caseWork);
throw new RuntimeException(
'Expected replacement ownership refusal.'); }
122 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'ownership differs'),
'Cleanup preserves the replacement directory'); }
123 deployCheck(
true, is_dir($privateTarget),
'Replacement directory survives');
124 rmdir($privateTarget); rename($caseWork.
'/original-private', $privateTarget);
127 deployCheck(
false, is_file($caseWork.
'/deployment.json'),
'Cleanup completes for empty destinations');
128 deployCheck([], glob($caseWork.
'/.lgv-tz-empty-*'),
'No empty-directory backups left');
129 if ($scenario ===
'published') {
130 deployCheck(
'installed file', file_get_contents($privateTarget.
'/fixture.txt'),
'Publish into the selected private directory');
131 deployCheck(
'installed file', file_get_contents($publicTarget.
'/fixture.txt'),
'Publish into the selected public directory');
135 deployCheck($privatePermissions, fileperms($privateTarget) & 0777,
'Preserve original private directory permissions');
136 deployCheck($privateOwner, fileowner($privateTarget),
'Preserve original private directory owner');
137 deployCheck($privateGroup, filegroup($privateTarget),
'Preserve original private directory group');
138 deployCheck(
false, is_file($privateTarget.
'/.lgv-tz-install-owner'),
'Do not leave installation ownership in the original empty directory');
139 if ($scenario ===
'new-file' || $scenario ===
'public-new-file') {
140 deployCheck(
'keep this new file', file_get_contents($noteTarget.
'/.operator-note'),
'Preserve a file added during setup');
146 mkdir(
$work.
'/nonempty', 0700); file_put_contents(
$work.
'/nonempty/.hidden',
'keep');
149 try { deployEmptyDirectories([
'private' => $work.
'/linked']); throw new RuntimeException(
'Expected linked-target refusal.'); }
150 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'file or link'),
'Refuse linked destination directories'); }
153 $connection->exec(
'CREATE TABLE unrelated (value INTEGER)');
154 $connection->exec(
'INSERT INTO unrelated VALUES (42)');
155 foreach ([
false,
true] as $published) {
156 $token = bin2hex(random_bytes(32));
157 $connection->exec(
'CREATE TABLE timezones (id INTEGER)');
158 $connection->exec(
'CREATE TABLE lgv_tz_install_guard (token VARCHAR(64))');
159 $statement =
$connection->prepare(
'INSERT INTO lgv_tz_install_guard VALUES (?)');
160 $statement->execute([$token]);
162 $state = [
'settings' =>
$settings,
'token' => $token,
'guard_created' =>
true,
'database_created' =>
false,
163 'published' => $published,
'private_stage' =>
$work.
'/private-stage',
'public_stage' =>
$work.
'/public-stage',
164 'private' =>
$work.
'/private',
'public' =>
$work.
'/public'];
165 foreach ([
'private',
'public',
'private_stage',
'public_stage'] as $key) {
166 mkdir($state[$key], 0700); file_put_contents($state[$key].
'/.lgv-tz-install-owner', $token);
169 file_put_contents(
$work.
'/boundaries.zip',
'temporary'); file_put_contents(
$work.
'/boundaries.json',
'temporary');
173 deployCheck(42, (
int)
$connection->query(
'SELECT value FROM unrelated')->fetchColumn(),
'Preserve unrelated data');
174 deployCheck(
false, is_dir($state[
'private_stage']),
'Remove private staging directory');
175 deployCheck(
false, is_dir($state[
'public_stage']),
'Remove public staging directory');
176 deployCheck($published, is_dir($state[
'private']),
'Keep only published private directory');
177 deployCheck($published, is_dir($state[
'public']),
'Keep only published public directory');
180 deployCheck(
false, is_file(
$work.
'/deployment.json'),
'Remove recovery state after successful cleanup');
186 $connection->exec(
'CREATE TABLE timezones (id INTEGER)');
187 $connection->exec(
"CREATE TABLE lgv_tz_install_guard (token VARCHAR(64))");
188 $connection->exec(
"INSERT INTO lgv_tz_install_guard VALUES ('different-owner')");
189 $state[
'published'] =
false; $state[
'guard_created'] =
true;
191 file_put_contents(
$work.
'/boundaries.zip',
'temporary');
192 try {
deployCleanup(
$work);
throw new RuntimeException(
'Expected ownership refusal.'); }
193 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'ownership differs'),
'Reject foreign database ownership'); }
195 deployCheck(
false, is_file(
$work.
'/boundaries.zip'),
'Downloads removed before ownership refusal');
196 deployCheck(
true, is_file(
$work.
'/deployment.json'),
'Retain refused cleanup record');
197 $connection->exec(
'DROP TABLE lgv_tz_install_guard');
198 $state[
'guard_created'] =
false;
199 mkdir($state[
'private'], 0700);
200 file_put_contents($state[
'private'].
'/.lgv-tz-install-owner',
'different-directory-owner');
202 try {
deployCleanup(
$work);
throw new RuntimeException(
'Expected directory refusal.'); }
203 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'ownership differs'),
'Reject foreign directory ownership'); }
204 deployCheck(
true, is_dir($state[
'private']),
'Preserve foreign directory');
205 deployCheck(0600, fileperms(
$work.
'/deployment.json') & 0777,
'Recovery credentials are owner-only');
208 mkdir(
$work.
'/web', 0700); mkdir(
$work.
'/fresh-run', 0700);
211 $answers = array_merge($answers, [
'n',
$work.
'/web',
'https://example.com/nested',
'service',
$work.
'/installer-private', posix_getgrgid(posix_getegid())[
'name']]);
212 $terminal = fopen(
'php://memory',
'w+b');
213 fwrite($terminal, implode(
"\n", $answers).
"\n"); rewind($terminal);
214 try {
deployRun(
$work.
'/fresh-run',
true, $terminal);
throw new RuntimeException(
'Expected existing-table refusal.'); }
215 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'already contains'),
'Real installer refuses existing timezone data'); }
216 finally { fclose($terminal); }
219 deployCheck([], glob(
$work.
'/web/*'),
'Refused installation publishes no endpoint');
220 deployCheck(
false, is_dir(
$work.
'/installer-private'),
'Refused installation leaves no private application');
221 deployCheck(
false, is_file(
$work.
'/fresh-run/deployment.json'),
'Refused installation cleanup completes');
224 mkdir(
$work.
'/installer-private', 0750); mkdir(
$work.
'/web/service', 0755);
226 $terminal = fopen(
'php://memory',
'w+b');
227 fwrite($terminal, implode(
"\n", $answers).
"\n"); rewind($terminal);
228 try {
deployRun(
$work.
'/fresh-run',
true, $terminal);
throw new RuntimeException(
'Expected existing-table refusal.'); }
229 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'already contains'),
'Empty destinations reach database validation'); }
230 finally { fclose($terminal); }
232 foreach ([
'private' =>
$work.
'/installer-private',
'public' =>
$work.
'/web/service'] as $key => $path) {
237 $terminal = fopen(
'php://memory',
'w+b');
238 fwrite($terminal, implode(
"\n", $answers).
"\n"); rewind($terminal);
240 deployRun(
$work.
'/fresh-run',
true, $terminal,
static function():
void { throw new RuntimeException(
'Fixture fresh Composer failure'); });
241 throw new RuntimeException(
'Expected fresh package failure.');
242 }
catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'Fixture fresh Composer failure'),
'Empty destinations reach package installation'); }
243 finally { fclose($terminal); }
246 foreach ([
'private' =>
$work.
'/installer-private',
'public' =>
$work.
'/web/service'] as $key => $path) {
252 $ownedName =
$name.
'_owned';
254 $ownedState = $state;
255 $ownedState[
'settings'][
'database'] = $ownedName;
256 $ownedState[
'guard_created'] =
false; $ownedState[
'database_created'] =
true;
257 foreach ([
'private',
'public',
'private_stage',
'public_stage'] as $key) { $ownedState[$key] =
$work.
'/absent-'.$key; }
260 $statement =
$admin->prepare(
$driver ===
'pgsql' ?
'SELECT COUNT(*) FROM pg_database WHERE datname = ?'
261 :
'SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = ?');
262 $statement->execute([$ownedName]);
263 deployCheck(0, (
int)$statement->fetchColumn(),
'Rollback removes newly created database');
267 $fixtureAutoloader = require dirname(__DIR__).
'/vendor/autoload.php';
271 [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]));
272 $private =
$work.
'/refresh-private'; $public =
$work.
'/web/refresh-service';
273 mkdir($private.
'/app/vendor', 0700,
true); mkdir($private.
'/tools', 0700); mkdir($public, 0700);
274 $originalToken = bin2hex(random_bytes(32));
275 foreach ([$private, $public] as
$directory) { file_put_contents(
$directory.
'/.lgv-tz-install-owner', $originalToken); }
276 $config =
"<?php\n// Keep this comment and the original credentials.\n";
278 'g_dbType' =>
$driver,
'g_dbHost' =>
$settings[
'host'],
'g_dbPort' =>
$settings[
'port'],
'g_server_secret' =>
'kept-secret'] as $key => $value) {
279 $config .=
'$'.$key.
' = '.var_export($value,
true).
";\n";
281 file_put_contents($private.
'/config.php',
$config);
282 file_put_contents($private.
'/installation.json', json_encode([
'release' =>
'original-boundaries',
'archive_sha256' =>
'original-digest',
'driver' =>
$driver,
'polygons' => 1]));
283 file_put_contents($private.
'/app/vendor/autoload.php',
'<?php');
284 file_put_contents($public.
'/index.php',
'<?php echo "old endpoint";');
285 file_put_contents($private.
'/notes.txt',
'keep private notes'); file_put_contents($public.
'/extra.txt',
'keep public file');
286 copy(dirname(__DIR__).
'/update.sh', $private.
'/update.sh');
287 foreach ([
'update.php',
'Setup.php'] as $file) { copy(dirname(__DIR__).
'/tools/'.$file, $private.
'/tools/'.$file); }
288 $refreshWork =
$work.
'/refresh-run'; mkdir($refreshWork, 0700);
290 $group = posix_getegid();
291 $serviceUrl =
'https://example.com/nested/refresh-service/';
293 $polygon =
$driver ===
'pgsql' ?
"encode(polygon, 'hex')" :
'HEX(polygon)';
294 return $connection->query(
'SELECT id, tzname, east, west, north, south, '.$polygon.
' AS polygon FROM timezones ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
296 $rowsBefore = $snapshot();
299 static function():
void {
throw new RuntimeException(
'Fixture Composer failure'); });
300 throw new RuntimeException(
'Expected failed refresh.');
301 }
catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'Fixture Composer failure'),
'Failed package install refuses publication'); }
303 $refreshState = json_decode(file_get_contents($refreshWork.
'/deployment.json'),
true);
304 rename($private, $refreshState[
'private_backup']); rename($refreshState[
'private_stage'], $private);
306 deployCheck(
$config, file_get_contents($private.
'/config.php'),
'Interrupted refresh restores original configuration');
307 deployCheck(
'<?php echo "old endpoint";', file_get_contents($public.
'/index.php'),
'Interrupted refresh preserves original endpoint');
308 deployCheck($originalToken, file_get_contents($private.
'/.lgv-tz-install-owner'),
'Interrupted refresh restores original ownership');
309 deployCheck($rowsBefore, $snapshot(),
'Failed refresh leaves polygons unchanged');
312 class_exists(LGV_TZ_Lookup_Query::class); class_exists(Composer\InstalledVersions::class);
313 $fixtureAutoloader->unregister();
317 $locationFixture =
'<?php $test_locations_param_array = [["title" => "Fixture location", "params" => ["lng" => 2, "lat" => 2], "result" => "Asia/Tokyo"]];';
318 if (isset(
$options[
'real-composer'])) {
322 copy(dirname(__DIR__).
'/composer.json',
$source.
'/composer.json');
323 copy(dirname(__DIR__).
'/LICENSE',
$source.
'/LICENSE');
325 file_put_contents(
$source.
'/src/TestLocations.php', $locationFixture);
330 $package = $app.
'/vendor/littlegreenviper/lgv_tz_lookup';
331 mkdir($package, 0755,
true);
333 file_put_contents($package.
'/src/TestLocations.php', $locationFixture);
334 file_put_contents($app.
'/vendor/autoload.php',
'<?php require '.var_export(dirname(__DIR__).
'/vendor/autoload.php',
true).
';');
335 Composer\InstalledVersions::reload([
'root' => [
'name' =>
'fixture/application',
'install_path' => $app],
339 if (
$driver ===
'pgsql') { $refreshAnswers[] =
$settings[
'admin_database']; }
340 $refreshAnswers = array_merge($refreshAnswers, [
'n',
$work.
'/web',
'https://example.com/nested',
'refresh-service', $private, posix_getgrgid($group)[
'name']]);
341 $terminal = fopen(
'php://memory',
'w+b'); fwrite($terminal, implode(
"\n", $refreshAnswers).
"\n"); rewind($terminal);
343 try {
deployRun($refreshWork,
true, $terminal, $installFixture); $report = ob_get_contents(); }
344 finally { ob_end_clean(); fclose($terminal); }
345 deployCheck(
$config, file_get_contents($private.
'/config.php'),
'Refresh preserves exact configuration bytes and secret');
346 deployCheck(
'original-boundaries', json_decode(file_get_contents($private.
'/installation.json'),
true)[
'release'],
'Refresh retains boundary release');
347 deployCheck($rowsBefore, $snapshot(),
'Successful refresh leaves polygons unchanged');
348 deployCheck(42, (
int)
$connection->query(
'SELECT value FROM unrelated')->fetchColumn(),
'Refresh preserves unrelated data');
349 deployCheck(
'keep private notes', file_get_contents($private.
'/notes.txt'),
'Refresh preserves additional private files');
350 deployCheck(
'keep public file', file_get_contents($public.
'/extra.txt'),
'Refresh preserves additional public files');
351 deployCheck(
true, str_contains($report,
'Test request: '.$serviceUrl.
'?test&secret=kept-secret'),
'Refresh prints full test URL with existing secret');
352 deployCheck(
false, is_file($refreshWork.
'/deployment.json'),
'Successful refresh removes recovery record');
353 deployCheck([], glob(
$work.
'/.lgv-tz-previous-*'),
'Successful refresh removes private backup');
354 deployCheck([], glob(
$work.
'/web/.lgv-tz-previous-*'),
'Successful refresh removes public backup');
355 $process = proc_open([PHP_BINARY, $public.
'/index.php',
'll=2,2',
'secret=kept-secret'],
356 [0 => [
'file',
'/dev/null',
'r'], 1 => [
'pipe',
'w'], 2 => STDERR], $pipes);
357 $response = stream_get_contents($pipes[1]); fclose($pipes[1]);
359 deployCheck(
'Asia/Tokyo', $response,
'Refreshed endpoint uses preserved private config and existing polygons');
361 $refreshedEndpoint = file_get_contents($public.
'/index.php');
364 if (!isset(
$options[
'real-composer'])) {
365 $badFixture =
static function(
string $app,
string $driver) use ($installFixture):
void {
366 $installFixture($app,
$driver);
367 file_put_contents($app.
'/vendor/littlegreenviper/lgv_tz_lookup/src/TestLocations.php',
368 '<?php $test_locations_param_array = [["params" => ["lng" => 2, "lat" => 2], "result" => "Wrong/Zone"]];');
372 throw new RuntimeException(
'Expected lookup-validation failure.');
373 }
catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'known-location tests failed'),
'Refuse code that fails existing boundary tests'); }
375 deployCheck($refreshedEndpoint, file_get_contents($public.
'/index.php'),
'Failed validation preserves the working public entry point');
376 deployCheck($rowsBefore, $snapshot(),
'Failed validation leaves populated boundaries unchanged');
377 deployCheck(
$config, file_get_contents($private.
'/config.php'),
'Failed validation preserves configuration');
379 $differentSettings =
$settings; $differentSettings[
'database'] .=
'_different';
380 try {
deployExistingInstallation($private, $public, $differentSettings);
throw new RuntimeException(
'Expected configuration refusal.'); }
381 catch (RuntimeException
$error) {
deployCheck(
true, str_contains(
$error->getMessage(),
'settings differ'),
'Refuse refresh with different database settings'); }
382 echo
'Passed '.$checks.
' '.
$driver.
" deployment recovery checks.\n";
static install(string $directory, string $driver, string $packagePath='', string $version='^1.4')
Install the library and optional loader, then require its Composer autoloader.
const PACKAGE
The package installed by both command-line tools.
if(!is_file($autoload)) $checks
if($driver==='pgsql') $admin
deployCheck($expected, $actual, string $label)
This checks an installer result.