From 567a614a02aa7010092ffc1c7fc99f95d7835bfe Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 14 Jul 2026 16:49:15 +0200 Subject: [PATCH] Update APIv2 endpoints --- app/Exceptions/NzbUploadException.php | 15 ++ app/Http/Controllers/Api/ApiV2Controller.php | 57 ++++- app/Services/Nzb/NzbUploadStagingService.php | 138 ++++++++++++ docs/nntmux_api_v2.md | 55 ++++- .../nntmux_api_v2.postman_collection.json | 69 +++++- routes/api.php | 1 + tests/Feature/ApiRequestMatrixTest.php | 144 ++++++++++++- tests/Unit/NzbUploadStagingServiceTest.php | 198 ++++++++++++++++++ 8 files changed, 666 insertions(+), 11 deletions(-) create mode 100644 app/Exceptions/NzbUploadException.php create mode 100644 app/Services/Nzb/NzbUploadStagingService.php create mode 100644 tests/Unit/NzbUploadStagingServiceTest.php diff --git a/app/Exceptions/NzbUploadException.php b/app/Exceptions/NzbUploadException.php new file mode 100644 index 000000000..6da77e6b1 --- /dev/null +++ b/app/Exceptions/NzbUploadException.php @@ -0,0 +1,15 @@ + false, 'offset' => 0, 'limit' => 0, 'query_hash' => '', 'cursor' => null]; @@ -70,6 +75,7 @@ class ApiV2Controller extends BasePageController ?ApiV2Presenter $presenter = null, ?ApiCapabilitiesService $capabilitiesService = null, ?SearchCursorCodec $cursorCodec = null, + ?NzbUploadStagingService $nzbUploadStagingService = null, ) { $this->releaseSearchService = $releaseSearchService; $this->releaseBrowseService = $releaseBrowseService; @@ -80,13 +86,14 @@ class ApiV2Controller extends BasePageController $this->presenter = $presenter ?? app(ApiV2Presenter::class); $this->capabilitiesService = $capabilitiesService ?? app(ApiCapabilitiesService::class); $this->cursorCodec = $cursorCodec ?? app(SearchCursorCodec::class); + $this->nzbUploadStagingService = $nzbUploadStagingService ?? app(NzbUploadStagingService::class); } /** * Validate API token and return cached user, or a normalized JSON API error on failure. * Caches user lookup for 5 minutes to reduce DB hits. */ - private function resolveUser(Request $request): User|JsonResponse + private function resolveUser(Request $request, bool $enforceRequestLimit = true): User|JsonResponse { if ($request->missing('api_token') || $request->isNotFilled('api_token')) { return apiJsonError(200, 'Missing parameter (api_token)'); @@ -106,11 +113,13 @@ class ApiV2Controller extends BasePageController $user->loadMissing('role'); - $userStats = $this->userStatsFor($user); - $thisRequests = (int) ($userStats->api_count ?? 0); - $maxRequests = (int) $user->role->apirequests; - if ($thisRequests > $maxRequests) { - return apiJsonError(500, 'Request limit reached'); + if ($enforceRequestLimit) { + $userStats = $this->userStatsFor($user); + $thisRequests = (int) ($userStats->api_count ?? 0); + $maxRequests = (int) $user->role->apirequests; + if ($thisRequests > $maxRequests) { + return apiJsonError(500, 'Request limit reached'); + } } return $user; @@ -744,6 +753,42 @@ class ApiV2Controller extends BasePageController return $this->jsonResponse(['data' => 'No such item (the guid you provided has no release in our database)'], 404); } + public function nzbAdd(Request $request): JsonResponse + { + $user = $this->resolveUser($request, false); + if ($user instanceof JsonResponse) { + return $user; + } + + if (! $user->can_post) { + return $this->jsonResponse(['error' => 'Insufficient privileges/not authorized'], 403); + } + + $nzb = $request->file('nzb'); + if (! $nzb instanceof UploadedFile) { + return $this->jsonResponse(['error' => 'Missing parameter (nzb file is required)'], 400); + } + + $nfo = $request->file('nfo'); + if ($nfo !== null && ! $nfo instanceof UploadedFile) { + return $this->jsonResponse(['error' => 'Invalid nfo upload'], 400); + } + + try { + $staged = $this->nzbUploadStagingService->stage($nzb, $nfo); + } catch (NzbUploadException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], $exception->status); + } + + return $this->jsonResponse([ + 'success' => true, + 'status' => 'staged', + 'name' => $staged['name'], + 'category' => $request->input('cat'), + 'files' => $staged['files'], + ], 201); + } + public function details(Request $request): JsonResponse { $user = $this->resolveUser($request); diff --git a/app/Services/Nzb/NzbUploadStagingService.php b/app/Services/Nzb/NzbUploadStagingService.php new file mode 100644 index 000000000..67699e926 --- /dev/null +++ b/app/Services/Nzb/NzbUploadStagingService.php @@ -0,0 +1,138 @@ +validateFile($nzb, 'nzb'); + $nfoDetails = $nfo === null ? null : $this->validateFile($nfo, 'nfo'); + + if ($nfoDetails !== null && strcasecmp($nzbBase, $nfoDetails[1]) !== 0) { + throw new NzbUploadException('NZB and NFO filenames must have matching basenames', 400); + } + + $folder = config('nntmux.nzb_upload_folder'); + if (! is_string($folder) || trim($folder) === '') { + throw new NzbUploadException('NZB upload folder is not configured', 500); + } + + $folder = rtrim($folder, DIRECTORY_SEPARATOR); + if (! $this->filesystem->isDirectory($folder) + && ! $this->filesystem->makeDirectory($folder, 0775, true)) { + throw new NzbUploadException('Failed to create NZB upload folder', 500); + } + + $files = [[$nzbName, $nzbContent, 'nzb']]; + if ($nfoDetails !== null) { + $files[] = [$nfoDetails[0], $nfoDetails[2], 'nfo']; + } + + foreach ($files as [$filename]) { + if ($this->filesystem->exists($folder.DIRECTORY_SEPARATOR.$filename)) { + throw new NzbUploadException("A staged file named {$filename} already exists", 409); + } + } + + $created = []; + try { + foreach ($files as [$filename, $content, $type]) { + $path = $folder.DIRECTORY_SEPARATOR.$filename; + if ($this->filesystem->put($path, $content) === false) { + throw new NzbUploadException("Failed to write {$filename} to disk", 500); + } + $created[] = $path; + } + } catch (\Throwable $exception) { + $rollbackFailed = false; + foreach ($created as $path) { + if ($this->filesystem->exists($path) && ! $this->filesystem->delete($path)) { + $rollbackFailed = true; + } + } + + if ($rollbackFailed) { + Log::channel('nzb_upload')->error('Failed to roll back partial API v2 upload', [ + 'files' => array_map('basename', $created), + ]); + + throw new NzbUploadException('Failed to write upload pair and roll back partial files', 500); + } + + if ($exception instanceof NzbUploadException) { + throw $exception; + } + + throw new NzbUploadException('Failed to write upload pair to disk', 500); + } + + foreach ($files as [$filename, , $type]) { + Log::channel('nzb_upload')->info('File uploaded by API v2', [ + 'filename' => $filename, + 'type' => $type, + ]); + } + + return [ + 'name' => $nzbBase, + 'files' => [ + 'nzb' => ['filename' => $nzbName, 'type' => 'nzb'], + 'nfo' => $nfoDetails === null ? null : ['filename' => $nfoDetails[0], 'type' => 'nfo'], + ], + ]; + } + + /** @return array{string,string,string} */ + private function validateFile(UploadedFile $file, string $expectedExtension): array + { + if (! $file->isValid()) { + throw new NzbUploadException("Invalid {$expectedExtension} upload", 400); + } + + $filename = $file->getClientOriginalName(); + if ($filename === '' + || $filename === '.' + || $filename === '..' + || str_contains($filename, '/') + || str_contains($filename, '\\') + || str_contains($filename, "\0")) { + throw new NzbUploadException("Unsafe {$expectedExtension} filename", 400); + } + + $extension = strtolower((string) pathinfo($filename, PATHINFO_EXTENSION)); + $basename = (string) pathinfo($filename, PATHINFO_FILENAME); + if ($extension !== $expectedExtension || trim($basename) === '') { + throw new NzbUploadException("The {$expectedExtension} field must contain a .{$expectedExtension} file", 400); + } + + try { + $content = $file->getContent(); + } catch (\Throwable) { + throw new NzbUploadException("Failed to read {$expectedExtension} upload", 400); + } + + if ($expectedExtension === 'nzb' && ! isValidNewznabNzb($content)) { + throw new NzbUploadException('Invalid NZB payload', 400); + } + if ($expectedExtension === 'nfo' && ($content === '' || strlen($content) > self::MAX_NFO_SIZE)) { + throw new NzbUploadException('NFO upload must not be empty or exceed 65535 bytes', 400); + } + + return [$filename, $basename, $content]; + } +} diff --git a/docs/nntmux_api_v2.md b/docs/nntmux_api_v2.md index 0b11a998b..20f70a346 100644 --- a/docs/nntmux_api_v2.md +++ b/docs/nntmux_api_v2.md @@ -21,6 +21,7 @@ https:///api/v2 - `GET /capabilities` is public. - All other v2 routes require `api_token`. - Route-level middleware uses token-aware throttling (`apiRateLimit`) and accepts `api_token` or the legacy `apikey` alias when that middleware is reused. +- `POST /nzbadd` is intentionally exempt from route-level rate limiting and API request quotas, and uploads are not recorded as API usage. Authentication and posting privileges still apply. - Controller-level auth errors return a JSON error envelope: ```json @@ -185,6 +186,57 @@ Selectors: - Auth: required - Requires `id` (GUID) +## 10) Add NZB + +- `POST /nzbadd` +- Auth: required; the user must have posting privileges (`can_post`) +- Content type: `multipart/form-data` + +Fields: + +| Field | Required | Notes | +|---|---|---| +| `api_token` | yes | API token for a verified, enabled user. | +| `nzb` | yes | Valid `.nzb` file staged for deferred import. | +| `nfo` | no | Matching `.nfo` file, maximum 65,535 bytes. | +| `cat` | no | Echoed as response metadata; it does not affect import categorization. | + +When `nfo` is supplied, its basename must match the NZB basename +case-insensitively (for example, `Release.nzb` and `Release.nfo`). The request +is atomic: both files are validated before staging, existing files are never +overwritten, and a partial write is rolled back. + +NZB-only example: + +```bash +curl -X POST -F "api_token=" -F "nzb=@Release.nzb" https:///api/v2/nzbadd +``` + +Paired example: + +```bash +curl -X POST -F "api_token=" -F "cat=5040" -F "nzb=@Release.nzb" -F "nfo=@Release.nfo" https:///api/v2/nzbadd +``` + +Successful staging returns HTTP `201`: + +```json +{ + "success": true, + "status": "staged", + "name": "Release", + "category": "5040", + "files": { + "nzb": { "filename": "Release.nzb", "type": "nzb" }, + "nfo": { "filename": "Release.nfo", "type": "nfo" } + } +} +``` + +Staging does not synchronously create a release. The existing importer consumes +the files from `NZB_UPLOAD_FOLDER` later. NZB-only responses set `files.nfo` to +`null`. + ## Response Models ### Search Envelope (`/search`, `/tv`, `/movies`, `/audio`, `/books`, `/anime`) @@ -226,9 +278,6 @@ The following are intentionally not part of v2 JSON API: - `commentadd` - `cartadd` - `cartdel` -- `nzbadd` - -NZB upload remains in v1 (`/api/v1/api?t=nzbadd`). ## Postman Collection diff --git a/docs/postman/nntmux_api_v2.postman_collection.json b/docs/postman/nntmux_api_v2.postman_collection.json index cc729139e..e61e42740 100644 --- a/docs/postman/nntmux_api_v2.postman_collection.json +++ b/docs/postman/nntmux_api_v2.postman_collection.json @@ -574,7 +574,74 @@ "body": "{\n \"error\": \"Missing parameter (api_token)\"\n}" } ] + }, + { + "name": "Add NZB", + "item": [ + { + "name": "Stage NZB Only", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { "key": "api_token", "value": "{{apiToken}}", "type": "text" }, + { "key": "nzb", "type": "file", "src": "Release.nzb" } + ] + }, + "description": "Stages one NZB for deferred import. The authenticated user must have posting privileges.", + "url": { + "raw": "{{baseUrl}}/api/v2/nzbadd", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "nzbadd"] + } + }, + "response": [] + }, + { + "name": "Stage Paired NZB and NFO", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { "key": "api_token", "value": "{{apiToken}}", "type": "text" }, + { "key": "cat", "value": "5040", "type": "text" }, + { "key": "nzb", "type": "file", "src": "Release.nzb" }, + { "key": "nfo", "type": "file", "src": "Release.nfo" } + ] + }, + "description": "Atomically stages an NZB and an optional NFO with a matching basename. Category is echoed metadata only.", + "url": { + "raw": "{{baseUrl}}/api/v2/nzbadd", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "nzbadd"] + } + }, + "response": [ + { + "name": "Created", + "originalRequest": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v2/nzbadd", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "nzbadd"] + } + }, + "status": "Created", + "code": 201, + "_postman_previewlanguage": "json", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "cookie": [], + "body": "{\n \"success\": true,\n \"status\": \"staged\",\n \"name\": \"Release\",\n \"category\": \"5040\",\n \"files\": {\n \"nzb\": { \"filename\": \"Release.nzb\", \"type\": \"nzb\" },\n \"nfo\": { \"filename\": \"Release.nfo\", \"type\": \"nfo\" }\n }\n}" + } + ] + } + ] } ] } - diff --git a/routes/api.php b/routes/api.php index 297dcc9e2..d7825fb50 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,7 @@ Route::prefix('v1')->group(function () { Route::prefix('v2')->group(function () { Route::get('capabilities', [ApiV2Controller::class, 'capabilities']); + Route::post('nzbadd', [ApiV2Controller::class, 'nzbAdd']); }); Route::prefix('v2')->middleware('apiRateLimit')->group(function () { diff --git a/tests/Feature/ApiRequestMatrixTest.php b/tests/Feature/ApiRequestMatrixTest.php index 03894b077..be22e18c1 100644 --- a/tests/Feature/ApiRequestMatrixTest.php +++ b/tests/Feature/ApiRequestMatrixTest.php @@ -9,26 +9,67 @@ use App\Http\Controllers\Api\ApiV2Controller; use App\Models\Category; use App\Services\Releases\ReleaseBrowseService; use App\Services\Releases\ReleaseSearchService; +use Illuminate\Contracts\Console\Kernel; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Filesystem\Filesystem; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Mockery; +use PDO; use ReflectionClass; use Tests\TestCase; class ApiRequestMatrixTest extends TestCase { + private string $nzbUploadFolder; + + private string $databasePath; + + /** @var array */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-api-matrix-test.sqlite'; + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('innerfileblacklist', '')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + protected function setUp(): void { parent::setUp(); config([ 'database.default' => 'sqlite', - 'database.connections.sqlite.database' => ':memory:', + 'database.connections.sqlite.database' => $this->databasePath, 'mail.from.address' => 'api-matrix@example.test', 'app.key' => 'base64:'.base64_encode(random_bytes(32)), ]); @@ -36,11 +77,30 @@ class ApiRequestMatrixTest extends TestCase DB::purge(); DB::reconnect(); Cache::flush(); + Schema::dropAllTables(); + + $this->nzbUploadFolder = sys_get_temp_dir().'/nntmux-api-matrix-'.bin2hex(random_bytes(6)); + config(['nntmux.nzb_upload_folder' => $this->nzbUploadFolder]); $this->createSchema(); $this->seedData(); } + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->nzbUploadFolder); + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + public function test_v1_invalid_sort_returns_xml_201_error(): void { $token = (string) DB::table('users')->value('api_token'); @@ -630,6 +690,71 @@ class ApiRequestMatrixTest extends TestCase ->assertJsonPath('password', 0); } + public function test_v2_nzbadd_stages_a_paired_nzb_and_nfo(): void + { + $token = (string) DB::table('users')->value('api_token'); + $nzb = UploadedFile::fake()->createWithContent( + 'Release.nzb', + '', + ); + $nfo = UploadedFile::fake()->createWithContent('Release.nfo', 'release information'); + + $this->post('/api/v2/nzbadd', [ + 'api_token' => $token, + 'cat' => '5040', + 'nzb' => $nzb, + 'nfo' => $nfo, + ])->assertCreated() + ->assertJsonPath('success', true) + ->assertJsonPath('status', 'staged') + ->assertJsonPath('name', 'Release') + ->assertJsonPath('category', '5040') + ->assertJsonPath('files.nzb.filename', 'Release.nzb') + ->assertJsonPath('files.nfo.filename', 'Release.nfo'); + + $this->assertFileExists($this->nzbUploadFolder.'/Release.nzb'); + $this->assertFileExists($this->nzbUploadFolder.'/Release.nfo'); + $this->assertSame(0, DB::table('user_requests')->count()); + } + + public function test_v2_nzbadd_ignores_an_exhausted_api_quota_without_recording_usage(): void + { + $userId = (int) DB::table('users')->value('id'); + $token = (string) DB::table('users')->value('api_token'); + DB::table('roles')->where('id', 1)->update(['apirequests' => 0]); + DB::table('user_requests')->insert([ + 'users_id' => $userId, + 'request' => '/api/v2/search?api_token=redacted', + 'timestamp' => now(), + ]); + + $this->post('/api/v2/nzbadd', [ + 'api_token' => $token, + 'nzb' => UploadedFile::fake()->createWithContent( + 'QuotaExempt.nzb', + '', + ), + ])->assertCreated(); + + $this->assertSame(1, DB::table('user_requests')->count()); + $this->assertFileExists($this->nzbUploadFolder.'/QuotaExempt.nzb'); + } + + public function test_v2_nzbadd_requires_posting_privileges(): void + { + DB::table('users')->update(['can_post' => false]); + $token = (string) DB::table('users')->value('api_token'); + + $this->post('/api/v2/nzbadd', [ + 'api_token' => $token, + 'nzb' => UploadedFile::fake()->createWithContent( + 'Release.nzb', + '', + ), + ])->assertForbidden() + ->assertJsonPath('error', 'Insufficient privileges/not authorized'); + } + private function createSchema(): void { Schema::create('roles', function (Blueprint $table): void { @@ -655,6 +780,7 @@ class ApiRequestMatrixTest extends TestCase $table->boolean('verified')->default(true); $table->timestamp('email_verified_at')->nullable(); $table->integer('rate_limit')->default(60); + $table->boolean('can_post')->default(true); $table->timestamps(); $table->softDeletes(); }); @@ -800,13 +926,29 @@ class ApiRequestMatrixTest extends TestCase }); } + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + private function seedData(): void { DB::table('settings')->insert([ ['name' => 'strapline', 'value' => 'Test strapline'], ['name' => 'metakeywords', 'value' => 'test,api'], ['name' => 'registerstatus', 'value' => '0'], + ['name' => 'categorizeforeign', 'value' => '0'], ['name' => 'catwebdl', 'value' => '0'], + ['name' => 'innerfileblacklist', 'value' => ''], ['name' => 'title', 'value' => 'NNTmux Test'], ['name' => 'home_link', 'value' => '/'], ]); diff --git a/tests/Unit/NzbUploadStagingServiceTest.php b/tests/Unit/NzbUploadStagingServiceTest.php new file mode 100644 index 000000000..df79e612f --- /dev/null +++ b/tests/Unit/NzbUploadStagingServiceTest.php @@ -0,0 +1,198 @@ + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-nzb-upload-service-test.sqlite'; + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('innerfileblacklist', '')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->uploadFolder = sys_get_temp_dir().'/nntmux-nzb-upload-'.bin2hex(random_bytes(6)); + config(['nntmux.nzb_upload_folder' => $this->uploadFolder]); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->uploadFolder); + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_it_stages_a_matching_nzb_and_nfo_pair(): void + { + $result = (new NzbUploadStagingService(new Filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Release.nfo', 'release information'), + ); + + $this->assertSame('Release', $result['name']); + $this->assertSame('Release.nzb', $result['files']['nzb']['filename']); + $this->assertSame('Release.nfo', $result['files']['nfo']['filename']); + $this->assertFileExists($this->uploadFolder.'/Release.nzb'); + $this->assertFileExists($this->uploadFolder.'/Release.nfo'); + } + + public function test_it_stages_an_nzb_without_an_nfo(): void + { + $result = (new NzbUploadStagingService(new Filesystem))->stage($this->nzb('Solo.nzb')); + + $this->assertNull($result['files']['nfo']); + $this->assertFileExists($this->uploadFolder.'/Solo.nzb'); + } + + public function test_it_rejects_mismatched_pair_names_before_writing(): void + { + try { + (new NzbUploadStagingService(new Filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Different.nfo', 'release information'), + ); + $this->fail('Expected a mismatched pair to be rejected.'); + } catch (NzbUploadException $exception) { + $this->assertSame(400, $exception->status); + } + + $this->assertDirectoryDoesNotExist($this->uploadFolder); + } + + public function test_it_rejects_an_invalid_nzb_payload(): void + { + $this->expectException(NzbUploadException::class); + $this->expectExceptionMessage('Invalid NZB payload'); + + (new NzbUploadStagingService(new Filesystem))->stage( + UploadedFile::fake()->createWithContent('Release.nzb', 'not xml'), + ); + } + + public function test_it_rejects_an_oversized_nfo(): void + { + $this->expectException(NzbUploadException::class); + $this->expectExceptionMessage('NFO upload must not be empty or exceed 65535 bytes'); + + (new NzbUploadStagingService(new Filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Release.nfo', str_repeat('x', 65536)), + ); + } + + public function test_it_rejects_a_collision_without_writing_either_file(): void + { + (new Filesystem)->makeDirectory($this->uploadFolder, 0775, true); + file_put_contents($this->uploadFolder.'/Release.nfo', 'pending'); + + try { + (new NzbUploadStagingService(new Filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Release.nfo', 'new information'), + ); + $this->fail('Expected an existing staged file to be rejected.'); + } catch (NzbUploadException $exception) { + $this->assertSame(409, $exception->status); + } + + $this->assertFileDoesNotExist($this->uploadFolder.'/Release.nzb'); + $this->assertSame('pending', file_get_contents($this->uploadFolder.'/Release.nfo')); + } + + public function test_it_rolls_back_the_nzb_when_the_nfo_write_fails(): void + { + $nzbPath = $this->uploadFolder.'/Release.nzb'; + $nfoPath = $this->uploadFolder.'/Release.nfo'; + $filesystem = Mockery::mock(Filesystem::class); + $filesystem->shouldReceive('isDirectory')->once()->with($this->uploadFolder)->andReturnTrue(); + $filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnFalse(); + $filesystem->shouldReceive('exists')->once()->with($nfoPath)->andReturnFalse(); + $filesystem->shouldReceive('put')->once()->with($nzbPath, Mockery::type('string'))->andReturn(100); + $filesystem->shouldReceive('put')->once()->with($nfoPath, 'release information')->andReturnFalse(); + $filesystem->shouldReceive('exists')->once()->with($nzbPath)->andReturnTrue(); + $filesystem->shouldReceive('delete')->once()->with($nzbPath)->andReturnTrue(); + + $this->expectException(NzbUploadException::class); + $this->expectExceptionMessage('Failed to write Release.nfo to disk'); + + (new NzbUploadStagingService($filesystem))->stage( + $this->nzb('Release.nzb'), + UploadedFile::fake()->createWithContent('Release.nfo', 'release information'), + ); + } + + private function nzb(string $name): UploadedFile + { + return UploadedFile::fake()->createWithContent( + $name, + '', + ); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +}