Add plugin marketplace

This commit is contained in:
Pig Fang 2018-08-19 11:39:14 +08:00
parent f25fdad9dc
commit 6b4812b251
16 changed files with 929 additions and 22 deletions

View File

@ -6,7 +6,6 @@ git:
cache:
directories:
- vendor
- plugins
- node_modules
env:

View File

@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use Exception;
use ZipArchive;
use Illuminate\Http\Request;
use Composer\Semver\Comparator;
use App\Services\PluginManager;
class MarketController extends Controller
{
/**
* Guzzle HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $guzzle;
/**
* Default request options for Guzzle HTTP client.
*
* @var array
*/
protected $guzzleConfig;
/**
* Cache for plugins registry.
*
* @var array
*/
protected $registryCache;
public function __construct(\GuzzleHttp\Client $guzzle)
{
$this->guzzle = $guzzle;
$this->guzzleConfig = [
'headers' => ['User-Agent' => config('secure.user_agent')],
'verify' => config('secure.certificates')
];
}
public function marketData()
{
$plugins = collect($this->getAllAvailablePlugins())->map(function ($item) {
$plugin = plugin($item['name']);
$manager = app('plugins');
if ($plugin) {
$item['enabled'] = $plugin->isEnabled();
$item['installed'] = $plugin->version;
$item['update_available'] = Comparator::greaterThan($item['version'], $item['installed']);
} else {
$item['installed'] = false;
}
$requirements = array_get($item, 'require', []);
unset($item['require']);
$item['dependencies'] = [
'isRequirementsSatisfied' => $manager->isRequirementsSatisfied($requirements),
'requirements' => $requirements,
'unsatisfiedRequirements' => $manager->getUnsatisfiedRequirements($requirements)
];
return $item;
});
return $plugins;
}
public function checkUpdates()
{
$pluginsHaveUpdate = collect($this->getAllAvailablePlugins())->filter(function ($item) {
$plugin = plugin($item['name']);
return $plugin && Comparator::greaterThan($item['version'], $plugin->version);
});
return json([
'available' => $pluginsHaveUpdate->isNotEmpty(),
'plugins' => $pluginsHaveUpdate->values()->all()
]);
}
public function download(Request $request, PluginManager $manager)
{
$name = $request->get('name');
$metadata = $this->getPluginMetadata($name);
if (! $metadata) {
return json(trans('admin.plugins.market.non-existent', ['plugin' => $name]), 1);
}
// Gather plugin distribution URL
$url = $metadata['dist']['url'];
$filename = array_last(explode('/', $url));
$plugins_dir = $manager->getPluginsDir();
$tmp_path = $plugins_dir.DIRECTORY_SEPARATOR.$filename;
// Download
try {
$this->guzzle->request('GET', $url, array_merge($this->guzzleConfig, [
'sink' => $tmp_path
]));
} catch (Exception $e) {
report($e);
return json(trans('admin.plugins.market.download-failed', ['error' => $e->getMessage()]), 2);
}
// Check file's sha1 hash
if (sha1_file($tmp_path) !== $metadata['dist']['shasum']) {
@unlink($tmp_path);
return json(trans('admin.plugins.market.shasum-failed'), 3);
}
// Unzip
$zip = new ZipArchive();
$res = $zip->open($tmp_path);
if ($res === true) {
if ($zip->extractTo($plugins_dir) === false) {
return json(trans('admin.plugins.market.unzip-failed', ['error' => 'Unable to extract the file.']), 4);
}
} else {
return json(trans('admin.plugins.market.unzip-failed', ['error' => $res]), 4);
}
$zip->close();
@unlink($tmp_path);
return json(trans('admin.plugins.market.install-success'), 0);
}
protected function getPluginMetadata($name)
{
return collect($this->getAllAvailablePlugins())->where('name', $name)->first();
}
protected function getAllAvailablePlugins()
{
if (app()->environment('testing') || ! $this->registryCache) {
try {
$pluginsJson = $this->guzzle->request(
'GET', config('plugins.registry'), $this->guzzleConfig
)->getBody();
} catch (Exception $e) {
throw new Exception(trans('admin.plugins.market.connection-error', [
'error' => htmlentities($e->getMessage())
]));
}
$this->registryCache = json_decode($pluginsJson, true);
}
return array_get($this->registryCache, 'packages', []);
}
}

View File

@ -11,7 +11,7 @@ use App\Services\PluginManager;
class PluginController extends Controller
{
public function config($name, Request $request)
public function config($name)
{
$plugin = plugin($name);

View File

@ -277,20 +277,24 @@ class PluginManager
/**
* Get the unsatisfied requirements of plugin.
*
* @param string|Plugin $plugin
* @param string|Plugin|array $plugin
* @return array
*/
public function getUnsatisfiedRequirements($plugin)
{
if (! $plugin instanceof Plugin) {
$plugin = $this->getPlugin($plugin);
}
if (is_array($plugin)) {
$requirements = $plugin;
} else {
if (! $plugin instanceof Plugin) {
$plugin = $this->getPlugin($plugin);
}
if (! $plugin) {
throw new \InvalidArgumentException('Plugin with given name does not exist.');
}
if (! $plugin) {
throw new \InvalidArgumentException('Plugin with given name does not exist.');
}
$requirements = $plugin->getRequirements();
$requirements = $plugin->getRequirements();
}
$unsatisfied = [];
@ -334,7 +338,7 @@ class PluginManager
/**
* Whether the plugin's requirements are satisfied.
*
* @param string|Plugin $plugin
* @param string|Plugin|array $plugin
* @return bool
*/
public function isRequirementsSatisfied($plugin)
@ -347,7 +351,7 @@ class PluginManager
*
* @return string
*/
protected function getPluginsDir()
public function getPluginsDir()
{
return config('plugins.directory') ?: base_path('plugins');
}

View File

@ -22,4 +22,14 @@ return [
|
*/
'url' => menv('PLUGINS_URL'),
/*
|--------------------------------------------------------------------------
| Plugins Market Source
|--------------------------------------------------------------------------
|
| Specify where to get plugins' metadata for plugin market.
|
*/
'registry' => menv('PLUGINS_REGISTRY', 'https://coding.net/u/printempw/p/bs-plugins-archive/git/raw/master/plugins.json'),
];

View File

@ -0,0 +1,207 @@
<template>
<section class="content">
<vue-good-table
:rows="plugins"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
styleClass="vgt-table striped"
>
<template slot="table-row" slot-scope="props">
<span v-if="props.column.field === 'title'">
<strong>{{ props.formattedRow[props.column.field] }}</strong>
<div>{{ props.row.name }}</div>
</span>
<span v-else-if="props.column.field === 'dependencies'">
<span
v-if="props.row.dependencies.requirements.length === 0"
><i v-t="'admin.noDependencies'"></i></span>
<div v-else>
<span
v-for="(semver, dep) in props.row.dependencies.requirements"
:key="dep"
class="label"
:class="`bg-${dep in props.row.dependencies.unsatisfiedRequirements ? 'red' : 'green'}`"
>
{{ dep }}: {{ semver }}
<br>
</span>
</div>
</span>
<span v-else-if="props.column.field === 'operations'">
<template v-if="props.row.installed">
<button
v-if="props.row.update_available"
class="btn btn-success btn-sm"
@click="updatePlugin(props.row)"
:disabled="installing === props.row.name"
>
<template v-if="installing === props.row.name">
<i class="fas fa-spinner fa-spin"></i> {{ $t('admin.pluginUpdating') }}
</template>
<template v-else>
<i class="fas fa-sync-alt"></i> {{ $t('admin.updatePlugin') }}
</template>
</button>
<button v-else-if="props.row.enabled" class="btn btn-primary btn-sm" disabled>
<i class="fas fa-check"></i> {{ $t('admin.statusEnabled') }}
</button>
<button v-else class="btn btn-primary btn-sm" @click="enablePlugin(props.row)">
<i class="fas fa-plug"></i> {{ $t('admin.enablePlugin') }}
</button>
</template>
<button
v-else
class="btn btn-default btn-sm"
@click="installPlugin(props.row)"
:disabled="installing === props.row.name"
>
<template v-if="installing === props.row.name">
<i class="fas fa-spinner fa-spin"></i> {{ $t('admin.pluginInstalling') }}
</template>
<template v-else>
<i class="fas fa-download"></i> {{ $t('admin.installPlugin') }}
</template>
</button>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</section>
</template>
<script>
import { VueGoodTable } from 'vue-good-table';
import 'vue-good-table/dist/vue-good-table.min.css';
import toastr from 'toastr';
import { swal } from '../../js/notify';
export default {
name: 'Market',
components: {
VueGoodTable
},
data() {
return {
plugins: [],
columns: [
{ field: 'title', label: this.$t('admin.pluginTitle') },
{
field: 'description',
label: this.$t('admin.pluginDescription'),
sortable: false,
width: '37%'
},
{ field: 'author', label: this.$t('admin.pluginAuthor') },
{
field: 'version',
label: this.$t('admin.pluginVersion'),
sortable: false,
globalSearchDisabled: true
},
{
field: 'dependencies',
label: this.$t('admin.pluginDependencies'),
sortable: false,
globalSearchDisabled: true
},
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true
},
],
tableOptions: {
search: {
enabled: true,
placeholder: this.$t('vendor.datatable.search')
},
pagination: {
enabled: true,
nextLabel: this.$t('vendor.datatable.next'),
prevLabel: this.$t('vendor.datatable.prev'),
rowsPerPageLabel: this.$t('vendor.datatable.rowsPerPage'),
allLabel: this.$t('vendor.datatable.all'),
ofLabel: this.$t('vendor.datatable.of')
}
},
installing: '',
};
},
beforeMount() {
this.fetchData();
},
methods: {
async fetchData() {
this.plugins = await this.$http.get('/admin/plugins/market-data');
},
async installPlugin({ name, originalIndex }) {
this.installing = name;
const { errno, msg } = await this.$http.post(
'/admin/plugins/market/download',
{ name }
);
if (errno === 0) {
toastr.success(msg);
this.plugins[originalIndex].update_available = false;
this.plugins[originalIndex].installed = true;
} else {
swal({ type: 'warning', text: msg });
}
this.installing = '';
},
async updatePlugin(plugin) {
const { dismiss } = await swal({
text: this.$t('admin.confirmUpdate', { plugin: plugin.title, old: plugin.installed, new: plugin.version }),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
this.installPlugin(plugin);
},
async enablePlugin({ name, dependencies: { requirements }, originalIndex }) {
if (requirements.length === 0) {
const { dismiss } = await swal({
text: this.$t('admin.noDependenciesNotice'),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
}
const { errno, msg, reason } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'enable', name }
);
if (errno === 0) {
toastr.success(msg);
this.$set(this.plugins[originalIndex], 'enabled', true);
} else {
const div = document.createElement('div');
const p = document.createElement('p');
p.textContent = msg;
div.appendChild(p);
const ul = document.createElement('ul');
reason.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
ul.appendChild(li);
});
div.appendChild(ul);
swal({
type: 'warning',
html: div.innerHTML.replace(/`([\w-_]+)`/g, '<code>$1</code>')
});
}
},
}
};
</script>

View File

@ -34,6 +34,11 @@ export default [
component: () => import('./admin/Customization'),
el: '#change-color'
},
{
path: 'admin/plugins/market',
component: () => import('./admin/Market'),
el: '.content'
},
{
path: 'auth/login',
component: () => import('./auth/Login'),

View File

@ -0,0 +1,131 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Market from '@/components/admin/Market';
import { flushPromises } from '../../utils';
import { swal } from '@/js/notify';
jest.mock('@/js/notify');
test('render dependencies', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] } },
{ name: 'b', dependencies: {
requirements: { 'a': '^1.0.0', 'c': '^2.0.0' }, unsatisfiedRequirements: { 'c': {} }
} }
]);
const wrapper = mount(Market);
await flushPromises();
expect(wrapper.text()).toContain('admin.noDependencies');
expect(wrapper.find('span.label.bg-green').text()).toBe('a: ^1.0.0');
expect(wrapper.find('span.label.bg-red').text()).toBe('c: ^2.0.0');
});
test('render operation buttons', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, installed: true, update_available: true },
{ name: 'b', dependencies: { requirements: [] }, installed: true, enabled: true },
{ name: 'c', dependencies: { requirements: [] }, installed: true },
{ name: 'd', dependencies: { requirements: [] }, installed: false },
]);
const wrapper = mount(Market);
await flushPromises();
const tbody = wrapper.find('tbody');
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.updatePlugin');
expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.statusEnabled');
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin');
expect(tbody.find('tr:nth-child(4)').text()).toContain('admin.installPlugin');
});
test('install plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'd', dependencies: { requirements: [] }, installed: false },
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValueOnce({ errno: 0, msg: '0' });
const wrapper = mount(Market);
await flushPromises();
const button = wrapper.find('button');
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/market/download',
{ name: 'd' }
);
button.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('admin.enablePlugin');
});
test('update plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
name: 'a',
version: '2.0.0',
dependencies: { requirements: [] },
installed: '1.0.0',
update_available: true
},
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' });
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Market);
await flushPromises();
const button = wrapper.find('button');
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/market/download',
{ name: 'a' }
);
});
test('enable installed plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, installed: true },
{ name: 'b', dependencies: { requirements: {} }, installed: true },
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1', reason: ['`a<div></div>`b'] })
.mockResolvedValue({ errno: 0, msg: '0' });
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValueOnce({});
const wrapper = mount(Market);
await flushPromises();
const buttons = wrapper.findAll('button');
buttons.at(0).trigger('click');
await flushPromises();
expect(swal).toBeCalledWith({
text: 'admin.noDependenciesNotice',
type: 'warning',
showCancelButton: true
});
expect(Vue.prototype.$http.post).not.toBeCalled();
buttons.at(0).trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
{ action: 'enable', name: 'a' }
);
expect(swal).toBeCalledWith({
type: 'warning',
html: '<p>1</p><ul><li>`a&lt;div&gt;&lt;/div&gt;`b</li></ul>'
});
buttons.at(1).trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('admin.statusEnabled');
});

View File

@ -102,23 +102,26 @@ plugins:
version: Version
dependencies: Dependencies
status:
title: Status
enabled: Enabled
disabled: Disabled
operations:
title: Operations
enabled: :plugin has been enabled.
unsatisfied:
notice: There are unsatisfied dependencies in the plugin, therefore we can't enable it. Please install or update the plugins listed below.
disabled: The <code>:name</code> plugin is not enabled
version: The version of <code>:name</code> does not satisfies the constraint <code>:constraint</code>
disabled: "The `:name` plugin is not enabled"
version: "The version of `:name` does not satisfies the constraint `:constraint`"
disabled: :plugin has been disabled.
deleted: The plugin was deleted successfully.
no-config-notice: The plugin is not installed or doesn't provide configuration page.
not-found: No such plugin.
market:
connection-error: Unable to connect to the plugins registry. :error
non-existent: The plugin :plugin does not exist.
download-failed: Unable to download the plugin. :error
shasum-failed: The downloaded file failed hash check, please retry.
unzip-failed: Unable to extract the plugin. :error
install-success: Plugin was installed.
empty: No result
update:

View File

@ -271,6 +271,17 @@ admin:
whyDependencies: What's this?
statusEnabled: Enabled
statusDisabled: Disabled
pluginTitle: Plugin
pluginAuthor: Author
pluginVersion: Version
pluginName: Name
pluginDescription: Description
pluginDependencies: Dependencies
installPlugin: Install
pluginInstalling: Installing...
updatePlugin: Update
pluginUpdating: Updating...
confirmUpdate: Are you sure to update ":plugin" from :old to :new?
enablePlugin: Enable
disablePlugin: Disable
confirmDeletion: Are you sure to delete this plugin?

View File

@ -112,13 +112,21 @@ plugins:
enabled: :plugin 已启用
unsatisfied:
notice: 无法启用此插件,因为其仍有未满足的依赖关系。请检查以下插件的版本,更新或安装它们:
disabled: <code>:name</code> 插件未启用
version: <code>:name</code> 的版本不符合要求 <code>:constraint</code>
disabled: "`:name` 插件未启用"
version: "`:name` 的版本不符合要求 `:constraint`"
disabled: :plugin 已禁用
deleted: 插件已被成功删除
no-config-notice: 插件未安装或未提供配置页面
not-found: 插件不存在
market:
connection-error: 无法连接至插件市场源,错误信息::error
non-existent: 插件 :plugin 不存在
download-failed: 插件下载失败,错误信息::error
shasum-failed: 文件校验失败,请尝试重新下载
unzip-failed: 插件解压缩失败,错误信息::error
install-success: 插件安装成功
empty: 无结果
update:

View File

@ -267,6 +267,17 @@ admin:
whyDependencies: 为什么会这样?
statusEnabled: 已启用
statusDisabled: 已禁用
pluginTitle: 插件
pluginAuthor: 作者
pluginVersion: 版本
pluginName: 插件标识
pluginDescription: 描述
pluginDependencies: 依赖关系
installPlugin: 安装
pluginInstalling: 正在安装...
updatePlugin: 更新
pluginUpdating: 正在更新...
confirmUpdate: 确定将「:plugin」从 :old 升级至 :new
enablePlugin: 启用插件
disablePlugin: 禁用插件
confirmDeletion: 真的要删除这个插件吗?

View File

@ -0,0 +1,20 @@
@extends('admin.master')
@section('title', trans('general.plugin-market'))
@section('content')
<!-- Content Wrapper. Contains page content -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<section class="content-header">
<h1>
@lang('general.plugin-market')
</h1>
</section>
<!-- Main content -->
<section class="content"></section>
</div><!-- /.content-wrapper -->
@endsection

View File

@ -123,11 +123,15 @@ Route::group(['middleware' => ['auth', 'admin'], 'prefix' => 'admin'], function
Route::group(['prefix' => 'plugins'], function () {
Route::get ('/data', 'PluginController@getPluginData');
Route::redirect('/market', 'https://github.com/printempw/blessing-skin-server/wiki/Plugins');
Route::view('/manage', 'admin.plugins');
Route::post('/manage', 'PluginController@manage');
Route::any ('/config/{name}', 'PluginController@config');
Route::view('/market', 'admin.market');
Route::get ('/market-data', 'MarketController@marketData');
Route::get ('/market/check', 'MarketController@checkUpdates');
Route::post('/market/download', 'MarketController@download');
});
Route::group(['prefix' => 'update'], function () {

View File

@ -0,0 +1,143 @@
<?php
namespace Tests\Concerns;
use ZipArchive;
trait GeneratesFakePlugins
{
/**
* Generate fake content of a plugin's package.json.
*
* @param array $info
* @return array
*/
protected function generateFakePlguinInfo($info = [])
{
return array_replace([
'name' => str_random(10),
'version' => '0.0.'.rand(1, 9),
'title' => str_random(20),
'description' => str_random(60),
'author' => array_get($info, 'author', str_random(10)),
'url' => 'https://'.str_random(10).'.test',
'namespace' => str_random(10),
'require' => [
'blessing-skin-server' => '^3.4.0'
]
], $info);
}
/**
* Generate plugin information for plugins registry (with "dist" field).
*
* @param array $info
* @return array
*/
protected function generateFakePluginsRegistryPackage($info = [])
{
return $this->generateFakePlguinInfo(array_replace([
'dist' => [
'type' => 'zip',
'url' => 'https://plugins-registry.test/'.str_random(10).'.zip',
'shasum' => strtolower(str_random(40))
]
], $info));
}
/**
* Generate fake content of a plugins registry.
* You can also pass two arguments (name and version) as a shortcut.
* If no argument is passed, we will randomly generate 10 fake plugins.
*
* @param array $plugins An array of plugin information.
* @return string JSON encoded content.
*/
protected function generateFakePluginsRegistry($plugins = [])
{
if (func_num_args() == 2) {
$plugins = [
[
'name' => func_get_arg(0),
'version' => func_get_arg(1)
]
];
}
$packages = [];
if (count($plugins) == 0) {
// Randomly generate 10 fake plugins
for ($i = 0; $i < 10; $i++) {
$packages[] = $this->generateFakePluginsRegistryPackage();
}
} else {
foreach ($plugins as $info) {
$packages[] = $this->generateFakePluginsRegistryPackage($info);
}
}
return json_encode([
'packages' => $packages
]);
}
/**
* Generate a fake plugin in plugins directory with given information.
*
* @param array $info The "name" field is required.
* @return void
*/
protected function generateFakePlugin($info)
{
$plugin_dir = base_path("plugins/{$info['name']}");
if (! is_dir($plugin_dir)) {
mkdir($plugin_dir);
}
// Generate fake config view
if ($config = array_get($info, 'config')) {
$views_path = "$plugin_dir/views";
if (! is_dir($views_path)) {
mkdir($views_path);
}
file_put_contents("$views_path/$config", str_random(64));
}
file_put_contents("$plugin_dir/package.json", json_encode(
$this->generateFakePlguinInfo($info)
));
file_put_contents("$plugin_dir/bootstrap.php", "<?php return function () { return '{$info['name']}'; };");
}
/**
* Generate a fake zip archive of given plugin.
*
* @param array $info Plugin information.
* @return string File path of generated zip archive.
*/
protected function generateFakePluginArchive($info)
{
$name = array_get($info, 'name');
$version = array_get($info, 'version');
$zipPath = storage_path("testing/{$name}_{$version}.zip");
if (file_exists($zipPath)) {
unlink($zipPath);
}
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
$zip->addEmptyDir($name);
$zip->addFromString("$name/package.json", json_encode(
$this->generateFakePlguinInfo($info)
));
$zip->close();
return $zipPath;
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace Tests;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\File;
use Tests\Concerns\MocksGuzzleClient;
use Tests\Concerns\GeneratesFakePlugins;
use GuzzleHttp\Exception\RequestException;
class MarketControllerTest extends TestCase
{
use MocksGuzzleClient;
use GeneratesFakePlugins;
protected function setUp()
{
parent::setUp();
return $this->actAs('superAdmin');
}
public function testDownload()
{
$this->setupGuzzleClientMock();
// Try to download a non-existent plugin
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry());
$this->postJson('/admin/plugins/market/download', [
'name' => 'non-existent-plugin'
])->assertJson([
'errno' => 1,
'msg' => trans('admin.plugins.market.non-existent', ['plugin' => 'non-existent-plugin'])
]);
// Can't download due to connection error
$this->appendToGuzzleQueue([
new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')),
new RequestException('Connection Error', new Request('GET', 'whatever')),
]);
$this->postJson('/admin/plugins/market/download', [
'name' => 'fake-test-download'
])->assertJson([
'errno' => 2,
'msg' => trans('admin.plugins.market.download-failed', ['error' => 'Connection Error'])
]);
// Downloaded plugin archive was tampered
$fakeArchive = $this->generateFakePluginArchive(['name' => 'fake-test-download', 'version' => '0.0.1']);
$this->appendToGuzzleQueue([
new Response(200, [], $this->generateFakePluginsRegistry('fake-test-download', '0.0.1')),
new Response(200, [], fopen($fakeArchive, 'r')),
]);
$this->postJson('/admin/plugins/market/download', [
'name' => 'fake-test-download'
])->assertJson([
'errno' => 3,
'msg' => trans('admin.plugins.market.shasum-failed')
]);
// Download and extract plugin
$shasum = sha1_file($fakeArchive);
$this->appendToGuzzleQueue([
new Response(200, [], $this->generateFakePluginsRegistry([
[
'name' => 'fake-test-download',
'version' => '0.0.1',
'dist' => [
'url' => 'whatever',
'shasum' => $shasum
]
]
])),
new Response(200, [], fopen($fakeArchive, 'r')),
]);
$this->postJson('/admin/plugins/market/download', [
'name' => 'fake-test-download'
])->assertJson([
'errno' => 0,
'msg' => trans('admin.plugins.market.install-success')
]);
$this->assertTrue(is_dir(base_path('plugins/fake-test-download')));
$this->assertTrue(empty(glob(base_path('plugins/fake-test-download_*.zip'))));
// Broken archive
file_put_contents($fakeArchive, 'broken');
$shasum = sha1_file($fakeArchive);
$this->appendToGuzzleQueue([
new Response(200, [], $this->generateFakePluginsRegistry([
[
'name' => 'fake-test-download',
'version' => '0.0.1',
'dist' => [
'url' => 'whatever',
'shasum' => $shasum
]
]
])),
new Response(200, [], fopen($fakeArchive, 'r')),
]);
$this->postJson('/admin/plugins/market/download', [
'name' => 'fake-test-download'
])->assertJson([
'errno' => 4,
'msg' => trans('admin.plugins.market.unzip-failed', ['error' => 19])
]);
}
public function testCheckUpdates()
{
$this->setupGuzzleClientMock();
// Not installed
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1'));
$this->getJson('/admin/plugins/market/check')
->assertJson([
'available' => false,
'plugins' => []
]);
// Generate fake plugin and refresh plugin manager
$this->generateFakePlugin(['name' => 'fake-test-update', 'version' => '0.0.1']);
$this->app->singleton('plugins', \App\Services\PluginManager::class);
// Plugin up-to-date
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '0.0.1'));
$this->getJson('/admin/plugins/market/check')
->assertJson([
'available' => false,
'plugins' => []
]);
// New version available
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake-test-update', '2.3.3'));
$this->getJson('/admin/plugins/market/check')
->assertJson([
'available' => true,
'plugins' => [[
'name' => 'fake-test-update'
]]
]);
}
public function testMarketData()
{
$this->setupGuzzleClientMock([
new RequestException('Connection Error', new Request('POST', 'whatever')),
new Response(200, [], $this->generateFakePluginsRegistry()),
]);
// Expected an exception, but unable to be asserted.
$this->getJson('/admin/plugins/market-data');
$this->getJson('/admin/plugins/market-data')
->assertJsonStructure([
[
'name',
'title',
'version',
'installed',
'description',
'author',
'dist',
'dependencies'
]
]);
// Detect installed plugins
$this->appendToGuzzleQueue(200, [], $this->generateFakePluginsRegistry('fake', '0.0.1'));
$this->generateFakePlugin(['name' => 'fake', 'version' => '0.0.1']);
$this->getJson('/admin/plugins/market-data')
->assertJsonStructure([
[
'name',
'title',
'version',
'installed',
'description',
'author',
'dist',
'dependencies'
]
]);
}
protected function tearDown()
{
// Clean fake plugins
File::deleteDirectory(base_path('plugins/fake-test-download'));
File::deleteDirectory(base_path('plugins/fake-test-update'));
File::deleteDirectory(base_path('plugins/fake'));
parent::tearDown();
}
}