Compare commits

...

14 Commits

Author SHA1 Message Date
1d60c5ad12 fix alignment issue 2024-10-20 10:25:33 -03:00
f1946574ec remove unneeded add-on 2024-10-18 21:32:37 -03:00
57d90948d8 Merge branch 'develop' into pg-support
# Conflicts:
#	docker/dev/Dockerfile
2024-10-18 16:13:20 -03:00
2e474d5589 Merge remote-tracking branch 'origin/pg-support' into pg-support 2024-10-18 15:51:45 -03:00
9a03a247d9 added postgresql support for broken functions 2024-10-18 15:48:32 -03:00
d499e2bfef Push PR and github branch builds to separate docker image
All checks were successful
Close stale issues and PRs / stale (push) Successful in 4s
2024-10-17 10:00:12 +10:00
2f9e062718 bump version 2024-10-17 09:05:25 +10:00
edbed1af90 Adds tests for settings endpoints
and reenables dns cert test
and fixes problems with schema
2024-10-17 08:48:47 +10:00
8497022e41 Merge pull request #4076 from Nephiel/4074-fix-1
All checks were successful
Close stale issues and PRs / stale (push) Successful in 5s
Fix schema validation errors
2024-10-17 07:07:05 +10:00
fa2c814fcb Fix schema validation in Default Site
Should solve error `data/value must match exactly one schema in oneOf` when setting the Default Site to 404 or 444. #4074
2024-10-16 19:09:14 +00:00
d96a3987c0 Fix forward_scheme validation in Redirection Host
Should solve error `data/forward_scheme must be equal to one of the allowed values` when configuring a Redirection Host with scheme set to `auto`. #4074
2024-10-16 19:04:50 +00:00
0987d7bba9 Merge branch 'NginxProxyManager:develop' into pg-support 2024-10-15 08:28:52 -03:00
b0b234ff7d Merge branch 'develop' into pg-support 2024-10-13 15:47:12 -03:00
10f61595e1 added postgresql support & added a postgres containers 2024-10-13 15:45:33 -03:00
36 changed files with 1307 additions and 626 deletions

View File

@ -1 +1 @@
2.12.0
2.12.1

10
Jenkinsfile vendored
View File

@ -43,7 +43,7 @@ pipeline {
steps {
script {
// Defaults to the Branch name, which is applies to all branches AND pr's
buildxPushTags = "-t docker.io/jc21/${IMAGE}:github-${BRANCH_LOWER}"
buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}"
}
}
}
@ -203,7 +203,13 @@ pipeline {
}
steps {
script {
npmGithubPrComment("Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/jc21/${IMAGE}) as `jc21/${IMAGE}:github-${BRANCH_LOWER}`\n\n**Note:** ensure you backup your NPM instance before testing this PR image! Especially if this PR contains database changes.", true)
npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on
[DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev)
as `nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}`
**Note:** ensure you backup your NPM instance before testing this image! Especially if there are database changes
**Note:** this is a different docker image namespace than the official image
""", true)
}
}
}

View File

@ -1,7 +1,7 @@
<p align="center">
<img src="https://nginxproxymanager.com/github.png">
<br><br>
<img src="https://img.shields.io/badge/version-2.12.0-green.svg?style=for-the-badge">
<img src="https://img.shields.io/badge/version-2.12.1-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>

View File

@ -29,6 +29,7 @@ if (config.debug()) {
app.set('json spaces', 2);
}
// CORS for everything
app.use(require('./lib/express/cors'));

View File

@ -9,6 +9,22 @@ function generateDbConfig() {
if (cfg.engine === 'knex-native') {
return cfg.knex;
}
if (cfg.engine === 'pg') {
return {
client: cfg.engine,
connection: {
host: cfg.host,
user: cfg.user,
password: cfg.password,
database: cfg.name,
port: cfg.port
},
migrations: {
tableName: 'migrations'
}
};
}
return {
client: cfg.engine,
connection: {

View File

@ -252,10 +252,14 @@ const internalAccessList = {
let query = accessListModel
.query()
.select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
.joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0')
.leftJoin('proxy_host', function() {
this.on('proxy_host.access_list_id', '=', 'access_list.id')
.andOn('proxy_host.is_deleted', '=', 0);
})
.where('access_list.is_deleted', 0)
.andWhere('access_list.id', data.id)
.allowGraph('[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]')
.groupBy('access_list.id')
.first();
if (access_data.permission_visibility !== 'all') {
@ -373,7 +377,10 @@ const internalAccessList = {
let query = accessListModel
.query()
.select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
.joinRaw('LEFT JOIN `proxy_host` ON `proxy_host`.`access_list_id` = `access_list`.`id` AND `proxy_host`.`is_deleted` = 0')
.leftJoin('proxy_host', function() {
this.on('proxy_host.access_list_id', '=', 'access_list.id')
.andOn('proxy_host.is_deleted', '=', 0);
})
.where('access_list.is_deleted', 0)
.groupBy('access_list.id')
.allowGraph('[owner,items,clients]')

View File

@ -22,9 +22,9 @@ const internalAuditLog = {
.allowGraph('[user]');
// Query is used for searching
if (typeof search_query === 'string') {
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where('meta', 'like', '%' + search_query + '%');
this.whereRaw('CAST(meta AS VARCHAR(65535)) like ? ESCAPE \'\'', '%' + search_query + '%');
});
}

View File

@ -409,16 +409,16 @@ const internalDeadHost = {
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,certificate]')
.orderBy('domain_names', 'ASC');
.orderByRaw('CAST(domain_names AS VARCHAR(65535)) ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string') {
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where('domain_names', 'like', '%' + search_query + '%');
this.whereRaw('CAST(domain_names AS VARCHAR(65535)) like ? ESCAPE \'\'', '%' + search_query + '%');
});
}

View File

@ -129,15 +129,15 @@ const internalHost = {
proxyHostModel
.query()
.where('is_deleted', 0)
.andWhere('domain_names', 'like', '%' + hostname + '%'),
.whereRaw('CAST(domain_names AS VARCHAR(65535)) like ? ESCAPE \'\'', '%'+hostname + '%'),
redirectionHostModel
.query()
.where('is_deleted', 0)
.andWhere('domain_names', 'like', '%' + hostname + '%'),
.whereRaw('CAST(domain_names AS VARCHAR(65535)) like ? ESCAPE \'\'', '%'+hostname + '%'),
deadHostModel
.query()
.where('is_deleted', 0)
.andWhere('domain_names', 'like', '%' + hostname + '%')
.whereRaw('CAST(domain_names AS VARCHAR(65535)) like ? ESCAPE \'\'', '%'+hostname + '%'),
];
return Promise.all(promises)

View File

@ -181,7 +181,9 @@ const internalNginx = {
* @param {Object} host
* @returns {Promise}
*/
generateConfig: (host_type, host) => {
generateConfig: (host_type, host_row) => {
// Prevent modifying the original object:
let host = JSON.parse(JSON.stringify(host_row));
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
if (config.debug()) {

View File

@ -409,6 +409,7 @@ const internalProxyHost = {
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('proxy_hosts:list')
.then((access_data) => {
let query = proxyHostModel
@ -416,16 +417,17 @@ const internalProxyHost = {
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,access_list,certificate]')
.orderBy('domain_names', 'ASC');
.orderByRaw('CAST(domain_names AS VARCHAR(65535) ) ASC')
;
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string') {
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where('domain_names', 'like', '%' + search_query + '%');
this.whereRaw('CAST(domain_names AS VARCHAR(65535) ) like ? ESCAPE \'\'', '%'+search_query + '%');
});
}
@ -436,6 +438,7 @@ const internalProxyHost = {
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}

View File

@ -20,7 +20,6 @@ const internalRedirectionHost = {
*/
create: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
@ -409,16 +408,16 @@ const internalRedirectionHost = {
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,certificate]')
.orderBy('domain_names', 'ASC');
.orderByRaw('CAST(domain_names AS VARCHAR(65535) ) ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string') {
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where('domain_names', 'like', '%' + search_query + '%');
this.whereRaw('CAST(domain_names AS VARCHAR(65535) ) like ? ESCAPE \'\'', '%' + search_query + '%');
});
}

View File

@ -298,16 +298,18 @@ const internalStream = {
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner]')
.orderBy('incoming_port', 'ASC');
//.orderBy('incoming_port', 'ASC')
.orderByRaw('CAST(incoming_port AS INTEGER) ASC')
;
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string') {
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where('incoming_port', 'like', '%' + search_query + '%');
this.whereRaw('CAST(incoming_port AS VARCHAR(65535)) like ? ESCAPE \'\'', '%' + search_query+ '%');
});
}

View File

@ -45,6 +45,25 @@ const configure = () => {
};
return;
}
const envPostgresqlHost = process.env.DB_POSTGRESQL_HOST || null;
const envPostgresqlUser = process.env.DB_POSTGRESQL_USER || null;
const envPostgresqlName = process.env.DB_POSTGRESQL_NAME || null;
if (envPostgresqlHost && envPostgresqlUser && envPostgresqlName) {
// we have enough mysql creds to go with mysql
logger.info('Using POSTGRESQL configuration');
instance = {
database: {
engine: 'pg',
host: envPostgresqlHost,
port: process.env.DB_POSTGRESQL_PORT || 5432,
user: envPostgresqlUser,
password: process.env.DB_POSTGRESQL_PASSWORD,
name: envPostgresqlName,
},
keys: getKeys(),
};
return;
}
const envSqliteFile = process.env.DB_SQLITE_FILE || '/data/database.sqlite';
logger.info(`Using Sqlite: ${envSqliteFile}`);

View File

@ -17,6 +17,9 @@ const boolFields = [
'preserve_path',
'ssl_forced',
'block_exploits',
'hsts_enabled',
'hsts_subdomains',
'http2_support',
];
class RedirectionHost extends Model {

View File

@ -21,8 +21,9 @@
"moment": "^2.29.4",
"mysql2": "^3.11.1",
"node-rsa": "^1.0.8",
"objection": "3.0.1",
"objection": "^3.0.1",
"path": "^0.12.7",
"pg": "^8.13.0",
"signale": "1.4.0",
"sqlite3": "5.1.6",
"temp-write": "^4.0.0"

View File

@ -23,9 +23,7 @@
"locations",
"hsts_enabled",
"hsts_subdomains",
"certificate",
"use_default_location",
"ipv6"
"certificate"
],
"additionalProperties": false,
"properties": {
@ -151,12 +149,6 @@
"$ref": "./access-list-object.json"
}
]
},
"use_default_location": {
"type": "boolean"
},
"ipv6": {
"type": "boolean"
}
}
}

View File

@ -28,7 +28,7 @@
},
"forward_scheme": {
"type": "string",
"enum": ["http", "https"]
"enum": ["auto", "http", "https"]
},
"forward_domain_name": {
"description": "Domain Name",

View File

@ -25,7 +25,7 @@
"value": {
"description": "Value in almost any form",
"example": "congratulations",
"oneOf": [
"anyOf": [
{
"type": "string",
"minLength": 1
@ -46,7 +46,10 @@
},
"meta": {
"description": "Extra metadata",
"example": {},
"example": {
"redirect": "http://example.com",
"html": "<h1>404</h1>"
},
"type": "object"
}
}

View File

@ -94,9 +94,7 @@
"avatar": "",
"roles": ["admin"]
},
"certificate": null,
"use_default_location": true,
"ipv6": true
"certificate": null
}
}
},

View File

@ -79,9 +79,7 @@
"nickname": "Admin",
"avatar": "",
"roles": ["admin"]
},
"use_default_location": true,
"ipv6": true
}
}
}
},

View File

@ -129,9 +129,7 @@
"roles": ["admin"]
},
"certificate": null,
"access_list": null,
"use_default_location": true,
"ipv6": true
"access_list": null
}
}
},

View File

@ -114,9 +114,7 @@
"avatar": "//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?default=mm",
"roles": ["admin"]
},
"access_list": null,
"use_default_location": true,
"ipv6": true
"access_list": null
}
}
},

View File

@ -114,9 +114,7 @@
"avatar": "",
"roles": ["admin"]
},
"certificate": null,
"use_default_location": true,
"ipv6": true
"certificate": null
}
}
},

View File

@ -99,9 +99,7 @@
"nickname": "Admin",
"avatar": "",
"roles": ["admin"]
},
"use_default_location": true,
"ipv6": true
}
}
}
},

View File

@ -129,9 +129,7 @@
"roles": ["admin"]
},
"certificate": null,
"access_list": null,
"use_default_location": true,
"ipv6": true
"access_list": null
}
}
},

View File

@ -13,7 +13,8 @@
"name": "settingID",
"schema": {
"type": "string",
"minLength": 1
"minLength": 1,
"enum": ["default-site"]
},
"required": true,
"description": "Setting ID",
@ -31,10 +32,21 @@
"minProperties": 1,
"properties": {
"value": {
"$ref": "../../../components/setting-object.json#/properties/value"
"type": "string",
"minLength": 1,
"enum": ["congratulations", "404", "444", "redirect", "html"]
},
"meta": {
"$ref": "../../../components/setting-object.json#/properties/meta"
"type": "object",
"additionalProperties": false,
"properties": {
"redirect": {
"type": "string"
},
"html": {
"type": "string"
}
}
}
}
}

View File

@ -15,11 +15,11 @@ const certbot = require('./lib/certbot');
const setupDefaultUser = () => {
return userModel
.query()
.select(userModel.raw('COUNT(`id`) as `count`'))
.select('id')
.where('is_deleted', 0)
.first()
.then((row) => {
if (!row.count) {
if (row.length === 0) {
// Create a new user and set password
let email = process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com';
let password = process.env.INITIAL_ADMIN_PASSWORD || 'changeme';
@ -77,11 +77,10 @@ const setupDefaultUser = () => {
const setupDefaultSettings = () => {
return settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.select('id')
.where({id: 'default-site'})
.first()
.then((row) => {
if (!row.count) {
if (!row.length || !row[0].id) {
settingModel
.query()
.insert({

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,8 @@ WORKDIR /root
COPY rootfs /
COPY scripts/install-s6 /tmp/install-s6
RUN /tmp/install-s6 "${TARGETPLATFORM}" && rm -f /tmp/install-s6
RUN chmod 644 -R /root/.cache
RUN rm -f /etc/nginx/conf.d/production.conf \
&& chmod 644 /etc/logrotate.d/nginx-proxy-manager \
&& /tmp/install-s6 "${TARGETPLATFORM}" \

View File

@ -0,0 +1,29 @@
# WARNING: This is a CI docker-compose file used for building and testing of the entire app, it should not be used for production.
services:
fullstack:
environment:
DB_POSTGRESQL_HOST: 'db'
DB_POSTGRESQL_PORT: '5432'
DB_POSTGRESQL_USER: 'npm'
DB_POSTGRESQL_PASSWORD: 'npmpass'
DB_POSTGRESQL_NAME: 'npm'
depends_on:
- db-postgresql
db-postgresql:
image: postgres:14.2-alpine
environment:
POSTGRES_PASSWORD: "npmpass"
POSTGRES_USER: "npm"
POSTGRES_DB: "npm"
ports:
- 5432:5432
volumes:
- postgres_vol:/var/lib/postgresql/data
networks:
- fulltest
volumes:
postgres_vol:

View File

@ -0,0 +1,89 @@
# WARNING: This is a DEVELOPMENT docker-compose file, it should not be used for production.
services:
npm1:
image: nginxproxymanager:dev
container_name: npm_core1
build:
context: ./
dockerfile: ./dev/Dockerfile
ports:
- 4080:80
- 4081:81
- 4443:443
networks:
- nginx_proxy_manager
environment:
PUID: 1000
PGID: 1000
FORCE_COLOR: 1
# specifically for dev:
DEBUG: 'true'
DEVELOPMENT: 'true'
LE_STAGING: 'true'
# db:
DB_POSTGRESQL_HOST: 'db1'
DB_POSTGRESQL_PORT: '5432'
DB_POSTGRESQL_USER: 'npm'
DB_POSTGRESQL_PASSWORD: 'npmpass'
DB_POSTGRESQL_NAME: 'npm'
# DB_SQLITE_FILE: "/data/database.sqlite"
# DISABLE_IPV6: "true"
volumes:
- npm_data1:/data
- le_data1:/etc/letsencrypt
- ../backend:/app
- ../frontend:/app/frontend
- ../global:/app/global
depends_on:
- db1
working_dir: /app
db1:
image: postgis/postgis:17-3.5-alpine
container_name: npm_db1
ports:
- 5432:5432
networks:
- nginx_proxy_manager
environment:
POSTGRES_PASSWORD: "npmpass"
POSTGRES_USER: "npm"
POSTGRES_DB: "npm"
volumes:
- db_data1:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: "admin@example.com"
PGADMIN_DEFAULT_PASSWORD: "changeme"
ports:
- 5080:80
networks:
- nginx_proxy_manager
depends_on:
- db1
swagger1:
image: swaggerapi/swagger-ui:latest
container_name: npm_swagger1
ports:
- 5082:80
environment:
URL: "http://npm:81/api/schema"
PORT: '80'
depends_on:
- npm1
volumes:
npm_data1:
name: npm_core_data
le_data1:
name: npm_le_data
db_data1:
name: npm_db_data1
networks:
nginx_proxy_manager:
name: npm_network

View File

@ -9,7 +9,7 @@ describe('Full Certificate Provisions', () => {
});
});
it.only('Should be able to create new http certificate', function() {
it('Should be able to create new http certificate', function() {
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/certificates',
@ -35,7 +35,7 @@ describe('Full Certificate Provisions', () => {
it('Should be able to create new DNS certificate with Powerdns', function() {
cy.task('backendApiPost', {
token: token,
path: '/api/certificates',
path: '/api/nginx/certificates',
data: {
domain_names: [
'website2.example.com'
@ -45,7 +45,8 @@ describe('Full Certificate Provisions', () => {
dns_challenge: true,
dns_provider: 'powerdns',
dns_provider_credentials: 'dns_powerdns_api_url = http://ns1.pdns:8081\r\ndns_powerdns_api_key = npm',
letsencrypt_agree: true
letsencrypt_agree: true,
propagation_seconds: 5,
},
provider: 'letsencrypt'
}

View File

@ -1,6 +1,6 @@
/// <reference types="cypress" />
describe('Hosts endpoints', () => {
describe('Proxy Hosts endpoints', () => {
let token;
before(() => {

View File

@ -0,0 +1,124 @@
/// <reference types="cypress" />
describe('Settings endpoints', () => {
let token;
before(() => {
cy.getToken().then((tok) => {
token = tok;
});
});
it('Get all settings', function() {
cy.task('backendApiGet', {
token: token,
path: '/api/settings',
}).then((data) => {
cy.validateSwaggerSchema('get', 200, '/settings', data);
expect(data.length).to.be.greaterThan(0);
});
});
it('Get default-site setting', function() {
cy.task('backendApiGet', {
token: token,
path: '/api/settings/default-site',
}).then((data) => {
cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
});
});
it('Default Site congratulations', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: 'congratulations',
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
expect(data).to.have.property('value');
expect(data.value).to.be.equal('congratulations');
});
});
it('Default Site 404', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: '404',
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
expect(data).to.have.property('value');
expect(data.value).to.be.equal('404');
});
});
it('Default Site 444', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: '444',
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
expect(data).to.have.property('value');
expect(data.value).to.be.equal('444');
});
});
it('Default Site redirect', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: 'redirect',
meta: {
redirect: 'https://www.google.com',
},
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
expect(data).to.have.property('value');
expect(data.value).to.be.equal('redirect');
expect(data).to.have.property('meta');
expect(data.meta).to.have.property('redirect');
expect(data.meta.redirect).to.be.equal('https://www.google.com');
});
});
it('Default Site html', function() {
cy.task('backendApiPut', {
token: token,
path: '/api/settings/default-site',
data: {
value: 'html',
meta: {
html: '<p>hello world</p>'
},
},
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
expect(data).to.have.property('id');
expect(data.id).to.be.equal('default-site');
expect(data).to.have.property('value');
expect(data.value).to.be.equal('html');
expect(data).to.have.property('meta');
expect(data.meta).to.have.property('html');
expect(data.meta.html).to.be.equal('<p>hello world</p>');
});
});
});

View File

@ -7,7 +7,7 @@ const BackendApi = function(config, token) {
this.axios = axios.create({
baseURL: config.baseUrl,
timeout: 60000,
timeout: 90000,
});
};