HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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);
    }
});