move_detect/cloud/tests/BasePathTest.php
유창욱 9386dfc580 feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).

## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
  StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
  real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
  8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.

## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
  (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
  walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
  scoped to the mount.

## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
  allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
  heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
  offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
  misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
  ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).

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

85 lines
4.2 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 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());
}
}