mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-15 03:01:37 -05:00
Added loginAs[Role] to e2e framework with example
closes: https://github.com/TryGhost/Toolbox/issues/342 refs:032a26f9f3
refs:588c9d04e8
- Now that the old `users:no-owner` (now named 'users') is working correctly :) - Was able to add loginAs[Role] methods for each staff role, so that it's possible to execute tests as that user and check permissions - Refactored the email preview tests to use the new e2e framework and these methods, as an example
This commit is contained in:
parent
588c9d04e8
commit
642b6ff8ae
3 changed files with 211 additions and 192 deletions
|
@ -40,6 +40,36 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Email Preview API As Contributor cannot send test email 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": null,
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "You do not have permission to sendTestEmail email_previews",
|
||||
"property": null,
|
||||
"type": "NoPermissionError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Email Preview API As Contributor cannot send test email 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "248",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Email Preview API As Editor can send test email 1: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
|
|
|
@ -1,61 +1,43 @@
|
|||
const should = require('should');
|
||||
const supertest = require('supertest');
|
||||
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyEtag, anyErrorId} = matchers;
|
||||
const assert = require('assert');
|
||||
|
||||
// @TODO: factor out these requires
|
||||
const ObjectId = require('bson-objectid');
|
||||
const testUtils = require('../../utils');
|
||||
const localUtils = require('./utils');
|
||||
const config = require('../../../core/shared/config');
|
||||
const models = require('../../../core/server/models/index');
|
||||
|
||||
describe('Email Preview API', function () {
|
||||
let request;
|
||||
let agent;
|
||||
|
||||
before(async function () {
|
||||
await localUtils.startGhost();
|
||||
request = supertest.agent(config.get('url'));
|
||||
await localUtils.doAuth(request, 'users:extra', 'newsletters', 'posts');
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('users', 'newsletters', 'posts');
|
||||
await agent.loginAsOwner();
|
||||
});
|
||||
|
||||
describe('Read', function () {
|
||||
it('can\'t retrieve for non existent post', async function () {
|
||||
const res = await request.get(localUtils.API.getApiQuery(`email_previews/posts/${ObjectId().toHexString()}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(404);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.errors);
|
||||
testUtils.API.checkResponseValue(jsonResponse.errors[0], [
|
||||
'message',
|
||||
'context',
|
||||
'type',
|
||||
'details',
|
||||
'property',
|
||||
'help',
|
||||
'code',
|
||||
'id',
|
||||
'ghostErrorCode'
|
||||
]);
|
||||
await agent.get('email_previews/posts/abcd1234abcd1234abcd1234/')
|
||||
.expectStatus(404)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
errors: [{
|
||||
id: anyErrorId
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('can read post email preview with fields', async function () {
|
||||
const res = await request
|
||||
.get(localUtils.API.getApiQuery(`email_previews/posts/${testUtils.DataGenerator.Content.posts[0].id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.email_previews);
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.email_previews[0], 'email_previews', null, null);
|
||||
await agent
|
||||
.get(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot();
|
||||
});
|
||||
|
||||
it('can read post email preview with email card and replacements', async function () {
|
||||
|
@ -67,32 +49,19 @@ describe('Email Preview API', function () {
|
|||
html: '<p>This is the actual post content...</p>',
|
||||
plaintext: 'This is the actual post content...',
|
||||
status: 'draft',
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904'
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904',
|
||||
published_at: new Date(0)
|
||||
});
|
||||
|
||||
await models.Post.add(post, {context: {internal: true}});
|
||||
const res = await request
|
||||
.get(localUtils.API.getApiQuery(`email_previews/posts/${post.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.email_previews);
|
||||
|
||||
jsonResponse.email_previews[0].html.should.match(/Hey there {unknown}/);
|
||||
jsonResponse.email_previews[0].html.should.match(/Welcome to your first Ghost email!/);
|
||||
jsonResponse.email_previews[0].html.should.match(/This is the actual post content\.\.\./);
|
||||
jsonResponse.email_previews[0].html.should.match(/Another email card with a similar replacement, see\?/);
|
||||
|
||||
jsonResponse.email_previews[0].plaintext.should.match(/Hey there {unknown}/);
|
||||
jsonResponse.email_previews[0].plaintext.should.match(/Welcome to your first Ghost email!/);
|
||||
jsonResponse.email_previews[0].plaintext.should.match(/This is the actual post content\.\.\./);
|
||||
jsonResponse.email_previews[0].plaintext.should.match(/Another email card with a similar replacement, see\?/);
|
||||
await agent
|
||||
.get(`email_previews/posts/${post.id}/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot();
|
||||
});
|
||||
|
||||
it('has custom content transformations for email compatibility', async function () {
|
||||
|
@ -104,35 +73,30 @@ describe('Email Preview API', function () {
|
|||
html: '<p>This is the actual post content...</p>',
|
||||
plaintext: 'This is the actual post content...',
|
||||
status: 'draft',
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904'
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904',
|
||||
published_at: new Date(0)
|
||||
});
|
||||
|
||||
await models.Post.add(post, {context: {internal: true}});
|
||||
|
||||
const res = await request
|
||||
.get(localUtils.API.getApiQuery(`email_previews/posts/${post.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.email_previews);
|
||||
|
||||
const [preview] = jsonResponse.email_previews;
|
||||
|
||||
preview.html.should.containEql('Testing links in email excerpt');
|
||||
|
||||
preview.html.should.match(/'/);
|
||||
preview.html.should.not.match(/'/);
|
||||
await agent
|
||||
.get(`email_previews/posts/${post.id}/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot()
|
||||
.expect(({body}) => {
|
||||
// Extra assert to ensure apostrophe is transformed
|
||||
assert.doesNotMatch(body.email_previews[0].html, /Testing links in email excerpt and apostrophes '/);
|
||||
assert.match(body.email_previews[0].html, /Testing links in email excerpt and apostrophes '/);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the posts newsletter', async function () {
|
||||
it('uses the posts newsletter by default', async function () {
|
||||
const defaultNewsletter = await models.Newsletter.getDefaultNewsletter();
|
||||
defaultNewsletter.id.should.not.eql(testUtils.DataGenerator.Content.newsletters[0].id, 'Should use a non-default newsletter for this test');
|
||||
const selectedNewsletter = fixtureManager.get('newsletters', 0);
|
||||
defaultNewsletter.id.should.not.eql(selectedNewsletter.id, 'Should use a non-default newsletter for this test');
|
||||
|
||||
const post = testUtils.DataGenerator.forKnex.createPost({
|
||||
id: ObjectId().toHexString(),
|
||||
|
@ -143,31 +107,29 @@ describe('Email Preview API', function () {
|
|||
plaintext: 'This is the actual post content...',
|
||||
status: 'scheduled',
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904',
|
||||
newsletter_id: testUtils.DataGenerator.Content.newsletters[0].id
|
||||
newsletter_id: selectedNewsletter.id,
|
||||
published_at: new Date(0)
|
||||
});
|
||||
|
||||
await models.Post.add(post, {context: {internal: true}});
|
||||
|
||||
const res = await request
|
||||
.get(localUtils.API.getApiQuery(`email_previews/posts/${post.id}/`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.email_previews);
|
||||
|
||||
const [preview] = jsonResponse.email_previews;
|
||||
preview.html.should.containEql(testUtils.DataGenerator.Content.newsletters[0].name);
|
||||
await agent
|
||||
.get(`email_previews/posts/${post.id}/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot()
|
||||
.expect(({body}) => {
|
||||
// Extra assert to ensure newsletter is correct
|
||||
assert.doesNotMatch(body.email_previews[0].html, new RegExp(defaultNewsletter.get('name')));
|
||||
assert.match(body.email_previews[0].html, new RegExp(selectedNewsletter.name));
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the newsletter provided through ?newsletter=slug', async function () {
|
||||
const defaultNewsletter = await models.Newsletter.getDefaultNewsletter();
|
||||
const selectedNewsletter = testUtils.DataGenerator.Content.newsletters[0];
|
||||
const selectedNewsletter = fixtureManager.get('newsletters', 0);
|
||||
|
||||
selectedNewsletter.id.should.not.eql(defaultNewsletter.id, 'Should use a non-default newsletter for this test');
|
||||
|
||||
|
@ -179,129 +141,122 @@ describe('Email Preview API', function () {
|
|||
html: '<p>This is the actual post content...</p>',
|
||||
plaintext: 'This is the actual post content...',
|
||||
status: 'draft',
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904'
|
||||
uuid: 'd52c42ae-2755-455c-80ec-70b2ec55c904',
|
||||
published_at: new Date(0)
|
||||
});
|
||||
|
||||
await models.Post.add(post, {context: {internal: true}});
|
||||
|
||||
const res = await request
|
||||
.get(localUtils.API.getApiQuery(`email_previews/posts/${post.id}/?newsletter=${selectedNewsletter.slug}`))
|
||||
.set('Origin', config.get('url'))
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200);
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.email_previews);
|
||||
|
||||
const [preview] = jsonResponse.email_previews;
|
||||
preview.html.should.containEql(testUtils.DataGenerator.Content.newsletters[0].name);
|
||||
await agent
|
||||
.get(`email_previews/posts/${post.id}/?newsletter=${selectedNewsletter.slug}`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot()
|
||||
.expect(({body}) => {
|
||||
// Extra assert to ensure newsletter is correct
|
||||
assert.doesNotMatch(body.email_previews[0].html, new RegExp(defaultNewsletter.get('name')));
|
||||
assert.match(body.email_previews[0].html, new RegExp(selectedNewsletter.name));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Owner', function () {
|
||||
it('can send test email', async function () {
|
||||
const url = localUtils.API.getApiQuery(`email_previews/posts/${testUtils.DataGenerator.Content.posts[0].id}/`);
|
||||
await request
|
||||
.post(url)
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(204)
|
||||
.expect((res) => {
|
||||
res.body.should.be.empty();
|
||||
});
|
||||
.expectStatus(204)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.expectEmptyBody();
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Admin', function () {
|
||||
before(async function () {
|
||||
const user = await testUtils.createUser({
|
||||
user: testUtils.DataGenerator.forKnex.createUser({email: 'admin+1@ghost.org'}),
|
||||
role: testUtils.DataGenerator.Content.roles[0].name
|
||||
});
|
||||
|
||||
request.user = user;
|
||||
await localUtils.doAuth(request);
|
||||
await agent.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('can send test email', async function () {
|
||||
const url = localUtils.API.getApiQuery(`email_previews/posts/${testUtils.DataGenerator.Content.posts[0].id}/`);
|
||||
await request
|
||||
.post(url)
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(204)
|
||||
.expect((res) => {
|
||||
res.body.should.be.empty();
|
||||
});
|
||||
.expectStatus(204)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.expectEmptyBody();
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Editor', function () {
|
||||
before(async function () {
|
||||
const user = await testUtils.createUser({
|
||||
user: testUtils.DataGenerator.forKnex.createUser({
|
||||
email: 'test+editor@ghost.org'
|
||||
}),
|
||||
role: testUtils.DataGenerator.Content.roles[1].name
|
||||
});
|
||||
|
||||
request.user = user;
|
||||
await localUtils.doAuth(request);
|
||||
await agent.loginAsEditor();
|
||||
});
|
||||
|
||||
it('can send test email', async function () {
|
||||
const url = localUtils.API.getApiQuery(`email_previews/posts/${testUtils.DataGenerator.Content.posts[0].id}/`);
|
||||
await request
|
||||
.post(url)
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(204)
|
||||
.expect((res) => {
|
||||
res.body.should.be.empty();
|
||||
});
|
||||
.expectStatus(204)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.expectEmptyBody();
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Author', function () {
|
||||
before(async function () {
|
||||
const user = await testUtils.createUser({
|
||||
user: testUtils.DataGenerator.forKnex.createUser({
|
||||
email: 'test+author@ghost.org'
|
||||
}),
|
||||
role: testUtils.DataGenerator.Content.roles[2].name
|
||||
});
|
||||
|
||||
request.user = user;
|
||||
await localUtils.doAuth(request);
|
||||
await agent.loginAsAuthor();
|
||||
});
|
||||
|
||||
it('cannot send test email', async function () {
|
||||
const url = localUtils.API.getApiQuery(`email_previews/posts/${testUtils.DataGenerator.Content.posts[0].id}/`);
|
||||
await request
|
||||
.post(url)
|
||||
.set('Origin', config.get('url'))
|
||||
.send({
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.set('Accept', 'application/json')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(403)
|
||||
.expect((res) => {
|
||||
res.body.should.be.an.Object().with.property('errors');
|
||||
.expectStatus(403)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
errors: [{
|
||||
id: anyErrorId
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Contributor', function () {
|
||||
before(async function () {
|
||||
await agent.loginAsContributor();
|
||||
});
|
||||
|
||||
it('cannot send test email', async function () {
|
||||
await agent
|
||||
.post(`email_previews/posts/${fixtureManager.get('posts', 0).id}/`)
|
||||
.body({
|
||||
emails: ['test@ghost.org']
|
||||
})
|
||||
.expectStatus(403)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
errors: [{
|
||||
id: anyErrorId
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,9 +2,17 @@ const TestAgent = require('./test-agent');
|
|||
const errors = require('@tryghost/errors');
|
||||
const DataGenerator = require('../fixtures/data-generator');
|
||||
|
||||
const ownerUser = {
|
||||
email: DataGenerator.Content.users[0].email,
|
||||
password: DataGenerator.Content.users[0].password
|
||||
const roleMap = {
|
||||
owner: 0,
|
||||
admin: 1,
|
||||
editor: 2,
|
||||
author: 3,
|
||||
contributor: 7
|
||||
};
|
||||
|
||||
const getRoleUserFromFixtures = (role) => {
|
||||
const {email, password} = DataGenerator.Content.users[roleMap[role]];
|
||||
return {email, password};
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -19,7 +27,13 @@ class AdminAPITestAgent extends TestAgent {
|
|||
super(app, options);
|
||||
}
|
||||
|
||||
async loginAs(email, password) {
|
||||
async loginAs(email, password, role) {
|
||||
if (role) {
|
||||
let user = getRoleUserFromFixtures(role);
|
||||
email = user.email;
|
||||
password = user.password;
|
||||
}
|
||||
|
||||
const res = await this.post('/session/')
|
||||
.body({
|
||||
grant_type: 'password',
|
||||
|
@ -32,6 +46,10 @@ class AdminAPITestAgent extends TestAgent {
|
|||
throw new errors.IncorrectUsageError({
|
||||
message: 'Ghost is redirecting, do you have an instance already running on port 2369?'
|
||||
});
|
||||
} else if (res.statusCode === 404 && role) {
|
||||
throw new errors.IncorrectUsageError({
|
||||
message: `Unable to login as ${role} - user not found. Did you pass 'users' to fixtureManager.init() ?`
|
||||
});
|
||||
} else if (res.statusCode !== 200 && res.statusCode !== 201) {
|
||||
throw new errors.IncorrectUsageError({
|
||||
message: res.body.errors[0].message
|
||||
|
@ -42,7 +60,23 @@ class AdminAPITestAgent extends TestAgent {
|
|||
}
|
||||
|
||||
async loginAsOwner() {
|
||||
await this.loginAs(ownerUser.email, ownerUser.password);
|
||||
await this.loginAs(null, null, 'owner');
|
||||
}
|
||||
|
||||
async loginAsAdmin() {
|
||||
await this.loginAs(null, null, 'admin');
|
||||
}
|
||||
|
||||
async loginAsEditor() {
|
||||
await this.loginAs(null, null, 'editor');
|
||||
}
|
||||
|
||||
async loginAsAuthor() {
|
||||
await this.loginAs(null, null, 'author');
|
||||
}
|
||||
|
||||
async loginAsContributor() {
|
||||
await this.loginAs(null, null, 'contributor');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue