fix: align upload-asset URL contract end-to-end

- ImageUploadService::store() now returns /assets/uploads/<token>/<file>
  so the URL the upload handler emits matches the new GET route.
- public/index.php registers a dedicated GET /assets/uploads/{userToken}/{filename}
  route; the legacy /assets/{kind}/{filename} is kept as a fallback.
- GetAssets reads the upload token from $request->server['__uploads_token']
  (the front controller threads it that way) instead of the cookies bag.
- Adds GetAssets unit tests for the upload-token happy path and a
  token-mismatch 404, plus a FullFlowTest step that uploads and re-fetches
  the asset through the front controller.
This commit is contained in:
Keith Solomon
2026-07-06 23:21:33 -05:00
parent 5d9af7a4fd
commit 61abb93e59
6 changed files with 136 additions and 8 deletions
+68 -5
View File
@@ -24,8 +24,8 @@ final class FullFlowTest extends TestCase
public function testTheFullCreateAndSaveFlow(): void
{
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$uploadsRoot = sys_get_temp_dir() . '/bf-fullflow-' . bin2hex(random_bytes(4));
mkdir($uploadsRoot, 0700, true);
// Use a deterministic secret so the upload token HMAC matches between upload and fetch.
putenv('BATTLEFORGE_SECRET=' . self::SECRET);
try {
// Step 1: GET home page.
@@ -71,19 +71,82 @@ final class FullFlowTest extends TestCase
self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null);
self::assertSame(1, $startJson['match']['round'] ?? null);
self::assertCount(6, $startJson['match']['units'] ?? []);
// Step 5: POST image upload, then GET the returned URL.
$this->uploadAndFetchAsset();
} finally {
// Always purge the per-install uploads dir after the test.
$uploadsRoot = __DIR__ . '/../../var/uploads';
if (is_dir($uploadsRoot)) {
foreach (glob($uploadsRoot . '/*/*') as $file) {
unlink($file);
@unlink($file);
}
foreach (glob($uploadsRoot . '/*') as $dir) {
rmdir($dir);
@rmdir($dir);
}
rmdir($uploadsRoot);
}
}
}
private function uploadAndFetchAsset(): void
{
$expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads');
$expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads');
// Create the multipart body manually so we can pre-compute the file we expect to find.
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
true,
);
$tmp = tempnam(sys_get_temp_dir(), 'bf-upload-');
file_put_contents($tmp, $png);
try {
[$token, $cookie] = CsrfToken::issue(self::SECRET);
$_COOKIE['__csrf'] = $cookie;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/assets/upload';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test';
$_SERVER['__csrf_secret'] = self::SECRET;
$_POST = ['_csrf' => $token];
$_FILES = [
'image' => [
'name' => 'test.png',
'type' => 'image/png',
'tmp_name' => $tmp,
'error' => 0,
'size' => strlen($png),
],
];
$this->rawBody = '';
$_SERVER['__raw_body'] = '';
$body = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'upload response: ' . $body);
$payload = json_decode($body, true);
$url = is_array($payload) ? (string) ($payload['url'] ?? '') : '';
self::assertNotSame('', $url);
self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url);
// Now fetch the asset back through the front controller.
$_COOKIE = [];
$_POST = [];
$_FILES = [];
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = $url;
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']);
$this->rawBody = '';
$fetchBody = $this->runFrontController();
self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody);
self::assertSame($png, $fetchBody);
// Clean up the uploaded file so the outer finally does not loop on it.
$filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/'));
@unlink($expectedDir . '/' . $expectedToken . '/' . $filename);
} finally {
@unlink($tmp);
}
}
private string $rawBody = '';
private int $lastStatus = 0;