- P3-2 [H] snapshot retention (7 days): snapshot_retention_days config;
SnapshotRepository::purgeOlderThan (interface + Pdo + double, files-then-rows,
bounded batch); opportunistic purge in the ingest path (Cafe24 has no cron).
- P4-1 [H] index.php sets display_errors=0 + a global exception handler → generic
{"error":"internal"} + error_log (no DSN/db_user leak); ships www/move_detect/.user.ini.
- P4-2 [H] ConfigController + HeartbeatController take an optional rate limiter +
clientIp; pre-auth per-IP anti-flood → 429 (were unthrottled).
- P4-3 [M] PdoNonceRepository::remember + PdoRateLimiterRepository::hit opportunistically
reap expired rows (Cafe24 has no cron; tables grew unbounded).
- P4-4 [M] Router::dispatch rawurldecodes captured params (Korean/space camera_id could
save zones but never load them).
Tests: python green; phpunit 84 green. See docs/delivery-audit.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
98 lines
5 KiB
PHP
98 lines
5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Tests;
|
|
|
|
use App\Http\{Request, Router, Response};
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* The app may be mounted at the web root (https://host/login) or in a subdirectory
|
|
* (https://host/move_detect/login) when the hosting account's www/ already serves another
|
|
* site. Routes are declared as '/login', '/api/events', ... — so if the mount prefix is not
|
|
* stripped from the request path, EVERY route 404s and the whole dashboard is dead.
|
|
*
|
|
* The prefix comes from SCRIPT_NAME, which the web server sets. It is never read from
|
|
* client-controlled input (a query param or header would let a caller relocate the app).
|
|
*/
|
|
final class BasePathTest extends TestCase
|
|
{
|
|
/** @return array<string, array{string, string, string}> */
|
|
public static function mounts(): array
|
|
{
|
|
// SCRIPT_NAME REQUEST_URI expected
|
|
return [
|
|
'web root' => ['/index.php', '/login', '/login'],
|
|
'web root, query' => ['/index.php', '/api/events?since=5', '/api/events'],
|
|
'web root, bare' => ['/index.php', '/', '/'],
|
|
'subdir' => ['/move_detect/index.php', '/move_detect/login', '/login'],
|
|
'subdir, query' => ['/move_detect/index.php', '/move_detect/api/events?since=5', '/api/events'],
|
|
'subdir, bare' => ['/move_detect/index.php', '/move_detect/', '/'],
|
|
'subdir, no slash' => ['/move_detect/index.php', '/move_detect', '/'],
|
|
'subdir, param route' => ['/move_detect/index.php', '/move_detect/events/42', '/events/42'],
|
|
'nested subdir' => ['/a/b/index.php', '/a/b/api/csrf', '/api/csrf'],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('mounts')]
|
|
public function testBasePathIsStrippedFromTheRoutedPath(
|
|
string $scriptName, string $requestUri, string $expected): void
|
|
{
|
|
$this->assertSame($expected, Request::pathFor($requestUri, Request::baseFrom($scriptName)));
|
|
}
|
|
|
|
public function testBaseFromScriptName(): void
|
|
{
|
|
$this->assertSame('', Request::baseFrom('/index.php'));
|
|
$this->assertSame('/move_detect', Request::baseFrom('/move_detect/index.php'));
|
|
$this->assertSame('/a/b', Request::baseFrom('/a/b/index.php'));
|
|
// Missing/odd SCRIPT_NAME must degrade to "mounted at the root", never to a prefix
|
|
// that would silently swallow the first path segment of every request.
|
|
$this->assertSame('', Request::baseFrom(''));
|
|
$this->assertSame('', Request::baseFrom('index.php'));
|
|
}
|
|
|
|
public function testAPathThatMerelyResemblesTheBaseIsNotTruncated(): void
|
|
{
|
|
// '/move_detected/x' starts with the literal string '/move_detect' but is NOT inside
|
|
// the mount. A naive str_starts_with() would rewrite it to 'ed/x'.
|
|
$base = Request::baseFrom('/move_detect/index.php');
|
|
$this->assertSame('/move_detected/x', Request::pathFor('/move_detected/x', $base));
|
|
}
|
|
|
|
public function testRouterMatchesDeclaredRoutesWhenMountedInASubdirectory(): void
|
|
{
|
|
$r = new Router();
|
|
$r->add('GET', '/api/events', fn() => Response::json(['ok' => true]));
|
|
$r->add('GET', '/events/{id}', fn($q, $p) => Response::json(['id' => $p['id']]));
|
|
|
|
$base = Request::baseFrom('/move_detect/index.php');
|
|
|
|
$list = new Request('GET', Request::pathFor('/move_detect/api/events', $base), [], '');
|
|
$this->assertSame(200, $r->dispatch($list)->status());
|
|
|
|
$detail = new Request('GET', Request::pathFor('/move_detect/events/42', $base), [], '');
|
|
$this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true));
|
|
}
|
|
|
|
public function testRouteParamsAreRawUrlDecoded(): void
|
|
{
|
|
// A camera_id with a space or Korean characters is %-encoded in the URL. Without
|
|
// decoding the captured param, GET /api/zones/{camera} can never match what
|
|
// POST /api/zones saved — zones save but never load back.
|
|
$r = new Router();
|
|
$r->add('GET', '/api/zones/{camera}', fn($q, $p) => Response::json(['camera' => $p['camera']]));
|
|
$res = $r->dispatch(new Request('GET', '/api/zones/cam%20A', [], ''));
|
|
$this->assertSame('cam A', json_decode($res->body(), true)['camera']);
|
|
$res2 = $r->dispatch(new Request('GET', '/api/zones/%EA%B0%80', [], '')); // "가"
|
|
$this->assertSame('가', json_decode($res2->body(), true)['camera']);
|
|
}
|
|
|
|
public function testFromGlobalsHonoursScriptNameWithoutTrustingClientInput(): void
|
|
{
|
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
|
$_SERVER['SCRIPT_NAME'] = '/move_detect/index.php';
|
|
$_SERVER['REQUEST_URI'] = '/move_detect/zones';
|
|
$this->assertSame('/zones', Request::fromGlobals()->path());
|
|
}
|
|
}
|