move_detect/cloud/tests/BasePathTest.php
유창욱 c66b034921 fix(cloud): logout order, url normalization, length caps, package hygiene (P4-9/10/11, P5-6)
- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning).
- P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL
  'Data too long' 500 the edge retries forever.
- P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'.
- P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package.

python + phpunit (86) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 09:48:35 +09:00

107 lines
5.4 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 testDoubleSlashUriIsNotMisrouted(): void
{
// A URI like '//api/events' makes parse_url treat 'api' as a host (scheme-relative),
// returning path '/events' — so '//api/events' would silently route to '/events'.
// Leading slashes must collapse to a single one.
$this->assertSame('/api/events', Request::pathFor('//api/events', ''));
$this->assertSame('/api/events', Request::pathFor('///api/events?x=1', ''));
}
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());
}
}