File: /home/xedaptot/be.naniguide.com/tests/Feature/ApiUpgradeTest.php
<?php
/**
* Feature tests for POST /api/v1/upgrade/run + POST /api/v1/upgrade/finalize.
*
* Verifies auth gating, input validation, error paths, and concurrency lock.
* Real patch apply (download + run) is NOT covered here — smoke-tested manually
* per .support/HANDBOOK_DETAILS.md "Method API" section.
*
* The 2-step split is intentional: /run replaces files in one worker, /finalize
* runs migrate + upgrade:finalize in a FRESH worker that loads the new code.
*/
use App\Model\Admin;
use App\Model\AdminGroup;
use App\Model\User;
use Illuminate\Http\UploadedFile;
uses(\Tests\TestCase::class);
const API_UPGRADE_URL = '/api/v1/upgrade/run';
const API_UPGRADE_RUN_FILE_URL = '/api/v1/upgrade/run-file';
const API_UPGRADE_FINALIZE_URL = '/api/v1/upgrade/finalize';
/**
* Create a User with api_token. Returns [User, token].
*/
function makeUserWithToken(string $emailSuffix = 'plain'): array
{
$user = new User();
$user->email = 'test-api-upgrade-' . $emailSuffix . '-' . uniqid() . '@example.test';
$user->first_name = 'Test';
$user->last_name = 'User';
$user->password = bcrypt('test-' . uniqid());
$user->save();
// User::boot() generates api_token in `creating` hook — read it back after save
$token = $user->fresh()->api_token;
return [$user, $token];
}
/**
* Promote a User to Admin under the default Administrator group (id=1, all perms = yes).
*/
function makeAdminFromUser(User $user): Admin
{
$admin = new Admin();
$admin->user_id = $user->id;
$admin->timezone = 'UTC';
$admin->status = 'active';
$admin->color_scheme = 'default';
$admin->admin_group_id = 1; // Administrator — has setting_upgrade_manager = yes
$admin->save();
return $admin;
}
afterEach(function () {
// Clean up any test users + admins we created
foreach (User::where('email', 'like', 'test-api-upgrade-%@example.test')->get() as $u) {
Admin::where('user_id', $u->id)->delete();
$u->delete();
}
// Release any leftover lock
$lockPath = storage_path('tmp/upgrade.lock');
if (file_exists($lockPath)) {
@unlink($lockPath);
}
});
// -----------------------------------------------------------------------------
// Auth gating
// -----------------------------------------------------------------------------
it('returns 401 without any token', function () {
$response = $this->postJson(API_UPGRADE_URL, ['url' => 'https://example.com/p.zip']);
expect($response->status())->toBe(401);
});
it('returns 401 with a valid api_token but the user is not an admin', function () {
[, $token] = makeUserWithToken('non-admin');
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, ['url' => 'https://example.com/p.zip']);
expect($response->status())->toBe(401);
expect($response->json('message'))->toBe('Unauthorized'); // from controller, not framework "Unauthenticated."
});
// -----------------------------------------------------------------------------
// Input validation (admin authenticated)
// -----------------------------------------------------------------------------
it('returns 422 when url is missing', function () {
[$user, $token] = makeUserWithToken('val-no-url');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, []);
expect($response->status())->toBe(422);
expect($response->json('message'))->toContain("'url' parameter");
});
it('returns 422 when url uses non-http scheme', function () {
[$user, $token] = makeUserWithToken('val-ftp');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, ['url' => 'ftp://example.com/x.zip']);
expect($response->status())->toBe(422);
expect($response->json('message'))->toContain('http or https');
});
it('returns 422 when timeout is below the allowed range', function () {
[$user, $token] = makeUserWithToken('val-t-low');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, [
'url' => 'https://example.com/p.zip',
'timeout' => 30,
]);
expect($response->status())->toBe(422);
expect($response->json('message'))->toContain('timeout');
});
it('returns 422 when timeout is above the allowed range', function () {
[$user, $token] = makeUserWithToken('val-t-high');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, [
'url' => 'https://example.com/p.zip',
'timeout' => 5000,
]);
expect($response->status())->toBe(422);
});
// -----------------------------------------------------------------------------
// Download failure → 400
// -----------------------------------------------------------------------------
it('returns 400 when download fails (unreachable URL)', function () {
[$user, $token] = makeUserWithToken('dl-fail');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, [
'url' => 'http://127.0.0.1:1/nope.zip',
'timeout' => 60,
]);
expect($response->status())->toBe(400);
expect($response->json('status'))->toBe('error');
});
// -----------------------------------------------------------------------------
// Concurrency lock → 409
// -----------------------------------------------------------------------------
it('returns 409 when another upgrade holds the lock', function () {
[$user, $token] = makeUserWithToken('lock');
makeAdminFromUser($user);
// Acquire the lock from this test process to simulate a concurrent run
$lockPath = storage_path('tmp/upgrade.lock');
if (!is_dir(dirname($lockPath))) {
@mkdir(dirname($lockPath), 0755, true);
}
$holder = fopen($lockPath, 'c');
expect(flock($holder, LOCK_EX | LOCK_NB))->toBeTrue();
try {
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_URL, ['url' => 'https://example.com/p.zip']);
expect($response->status())->toBe(409);
expect($response->json('message'))->toContain('Another upgrade');
} finally {
flock($holder, LOCK_UN);
fclose($holder);
}
});
// -----------------------------------------------------------------------------
// POST /api/v1/upgrade/run-file — multipart file upload
// -----------------------------------------------------------------------------
it('run-file returns 401 without any token', function () {
$response = $this->postJson(API_UPGRADE_RUN_FILE_URL, []);
expect($response->status())->toBe(401);
});
it('run-file returns 401 for non-admin token', function () {
[, $token] = makeUserWithToken('rf-non-admin');
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_RUN_FILE_URL, []);
expect($response->status())->toBe(401);
expect($response->json('message'))->toBe('Unauthorized');
});
it('run-file returns 422 when patch field is missing', function () {
[$user, $token] = makeUserWithToken('rf-no-file');
makeAdminFromUser($user);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_RUN_FILE_URL, []);
expect($response->status())->toBe(422);
expect($response->json('message'))->toContain("'patch' multipart file field");
});
it('run-file returns 400 when uploaded file is not a valid zip', function () {
[$user, $token] = makeUserWithToken('rf-bad-zip');
makeAdminFromUser($user);
$bogus = UploadedFile::fake()->createWithContent('patch.zip', 'not a real zip file');
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->post(API_UPGRADE_RUN_FILE_URL, ['patch' => $bogus]);
expect($response->status())->toBe(400);
expect($response->json('status'))->toBe('error');
});
it('run-file returns 409 when another upgrade holds the lock', function () {
[$user, $token] = makeUserWithToken('rf-lock');
makeAdminFromUser($user);
$lockPath = storage_path('tmp/upgrade.lock');
if (!is_dir(dirname($lockPath))) {
@mkdir(dirname($lockPath), 0755, true);
}
$holder = fopen($lockPath, 'c');
expect(flock($holder, LOCK_EX | LOCK_NB))->toBeTrue();
$bogus = UploadedFile::fake()->createWithContent('patch.zip', 'pkzip-stub');
try {
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->post(API_UPGRADE_RUN_FILE_URL, ['patch' => $bogus]);
expect($response->status())->toBe(409);
expect($response->json('message'))->toContain('Another upgrade');
} finally {
flock($holder, LOCK_UN);
fclose($holder);
}
});
// -----------------------------------------------------------------------------
// POST /api/v1/upgrade/finalize — auth + concurrency
// -----------------------------------------------------------------------------
it('finalize returns 401 without any token', function () {
$response = $this->postJson(API_UPGRADE_FINALIZE_URL, []);
expect($response->status())->toBe(401);
});
it('finalize returns 401 for non-admin token', function () {
[, $token] = makeUserWithToken('fin-non-admin');
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_FINALIZE_URL, []);
expect($response->status())->toBe(401);
expect($response->json('message'))->toBe('Unauthorized');
});
it('finalize returns 409 when another upgrade holds the lock', function () {
[$user, $token] = makeUserWithToken('fin-lock');
makeAdminFromUser($user);
$lockPath = storage_path('tmp/upgrade.lock');
if (!is_dir(dirname($lockPath))) {
@mkdir(dirname($lockPath), 0755, true);
}
$holder = fopen($lockPath, 'c');
expect(flock($holder, LOCK_EX | LOCK_NB))->toBeTrue();
try {
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])
->postJson(API_UPGRADE_FINALIZE_URL, []);
expect($response->status())->toBe(409);
expect($response->json('message'))->toContain('Another upgrade');
} finally {
flock($holder, LOCK_UN);
fclose($holder);
}
});